import { useEffect, useState } from "react"; import { RefreshCw } from "lucide-react"; import { Navigate, Outlet, useLocation } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { accessApi } from "@/api/access"; import { ApiError } from "@/api/client"; import { authApi } from "@/api/auth"; import { boardAccessQueryOptions } from "@/api/board-access-query"; import { useBootstrapQuery } from "@/api/bootstrap-query"; import { healthApi } from "@/api/health"; import { isTemporaryApiError } from "@/api/response"; import { useConnectivityStore } from "@/lib/connectivity"; import { reconnectDelayFor, retryWhileTransient } from "@/lib/query-client"; import { queryKeys } from "@/lib/queryKeys"; import { BootstrapPendingPage } from "@/components/BootstrapPendingPage"; import { PaperclipLoading } from "@/components/AnimatedPaperclipIcon"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { CloudSignIn } from "@/components/CloudSignIn"; import { useSuppressConnectionBanner } from "@/components/ConnectionStatusBanner"; import { clearCloudSignInAttempt } from "@/lib/cloud-sign-in"; /** Poll while the server reports `starting`; outages are polled by `lib/connectivity`. */ const SERVER_STARTING_POLL_MS = 5_000; export function CloudAccessError({ temporary, retrying, onRetry, }: { temporary: boolean; retrying: boolean; onRetry: () => void; }) { useSuppressConnectionBanner(temporary); return (
); } function NoBoardAccessPage() { return (

No organization access

This account is signed in, but it does not have an active organization membership or instance-admin access on this Paperclip instance.

Use an organization invite or sign in with an account that already belongs to this org.

); } export function CloudAccessGate({ allowMembershipRequest = false }: { allowMembershipRequest?: boolean } = {}) { const location = useLocation(); const queryClient = useQueryClient(); const connectivity = useConnectivityStore(); const [hasOpenedBoard, setHasOpenedBoard] = useState(false); // The board cannot open without these checks, so transient failures retry // until they succeed (never a 401/403). During an outage the retries pause // and the connectivity store's single probe loop decides when to resume. const healthQuery = useQuery({ queryKey: queryKeys.health, queryFn: () => healthApi.get(), retry: retryWhileTransient, retryDelay: reconnectDelayFor, refetchInterval: (query) => { const data = query.state.data; if (data?.status === "starting") return SERVER_STARTING_POLL_MS; return data?.deploymentMode === "authenticated" && data.bootstrapStatus === "bootstrap_pending" ? 2000 : false; }, refetchIntervalInBackground: true, }); const isAuthenticatedMode = healthQuery.data?.deploymentMode === "authenticated"; const isBootstrapPending = isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending"; const sessionQuery = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), enabled: isAuthenticatedMode, retry: retryWhileTransient, retryDelay: reconnectDelayFor, }); useEffect(() => { if (sessionQuery.data) clearCloudSignInAttempt(); }, [sessionQuery.data]); const sessionBootstrap = sessionQuery.isSuccess ? sessionQuery.data?.boardBootstrap : undefined; const bootstrap = sessionBootstrap?.userId === sessionQuery.data?.user.id && sessionBootstrap?.boardAccess.userId === sessionQuery.data?.user.id ? sessionBootstrap : undefined; const boardAccessQuery = useBootstrapQuery({ ...boardAccessQueryOptions( sessionQuery.data?.user.id ?? null, sessionQuery.data?.boardBootstrap, sessionQuery.dataUpdatedAt, () => { void queryClient.invalidateQueries({ queryKey: queryKeys.auth.session }); }, ), enabled: isAuthenticatedMode && !isBootstrapPending && !!sessionQuery.data, retry: retryWhileTransient, retryDelay: reconnectDelayFor, }, bootstrap ? { identity: bootstrap, data: bootstrap.boardAccess, updatedAt: sessionQuery.dataUpdatedAt } : undefined); const claimMutation = useMutation({ mutationFn: () => accessApi.claimBootstrapAdmin(), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session }); await queryClient.invalidateQueries({ queryKey: queryKeys.health }); await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); await queryClient.invalidateQueries({ queryKey: queryKeys.companies.stats }); await queryClient.invalidateQueries({ queryKey: queryKeys.access.currentBoardAccess }); }, }); const activeQueries = [ healthQuery, ...(isAuthenticatedMode ? [sessionQuery] : []), ...(isAuthenticatedMode && !isBootstrapPending && sessionQuery.data ? [boardAccessQuery] : []), ]; const isServerStarting = healthQuery.data?.status === "starting"; // A background outage must not unmount editors and discard drafts. Cached // access is only retained for transport failures; 401/403 still fail closed. const blockingError = activeQueries.find((query) => query.error && !isTemporaryApiError(query.error))?.error; // Nothing cached yet and the check is retrying a transient failure, or is // paused because the connectivity store has confirmed an outage. const isWaitingForConnection = activeQueries.some((query) => query.data === undefined && (query.fetchStatus === "paused" || isTemporaryApiError(query.error ?? query.failureReason))); const isLoading = healthQuery.isLoading || (isAuthenticatedMode && sessionQuery.isLoading) || (isAuthenticatedMode && !isBootstrapPending && !!sessionQuery.data && boardAccessQuery.isLoading); const hasBoardAccess = allowMembershipRequest || !isAuthenticatedMode || !!boardAccessQuery.data?.isInstanceAdmin || (boardAccessQuery.data?.companyIds.length ?? 0) > 0; const canAccessBoard = !isLoading && !blockingError && !isBootstrapPending && (!isAuthenticatedMode || !!sessionQuery.data) && hasBoardAccess; useEffect(() => { if (!canAccessBoard) setHasOpenedBoard(false); else if (healthQuery.data?.status === "ok") setHasOpenedBoard(true); }, [canAccessBoard, healthQuery.data?.status]); // A restarting server answers health with `starting`. Hand that to the // connectivity store so the app-level banner shows and its probe loop // refreshes everything once health reports `ok`. useEffect(() => { if (isServerStarting) connectivity.reportServerStarting(); }, [isServerStarting, connectivity]); if (blockingError || isWaitingForConnection || (isServerStarting && !hasOpenedBoard)) { return ( query.isFetching && query.failureCount === 0)} onRetry={() => { connectivity.probeNow(); for (const query of activeQueries) { if (query.error || query.data === undefined || (query === healthQuery && isServerStarting)) { // Skip the rest of the current backoff step: the user asked now. void query.refetch({ cancelRefetch: true }); } } }} /> ); } if (isLoading) { return ; } if (isAuthenticatedMode && healthQuery.data?.cloud && !sessionQuery.data) { return ; } if (isBootstrapPending) { const health = healthQuery.data; if (!health) { return ; } const claimError = claimMutation.error instanceof ApiError ? { status: claimMutation.error.status, message: claimMutation.error.message } : claimMutation.error instanceof Error ? { message: claimMutation.error.message } : null; return ( claimMutation.mutate()} /> ); } if (isAuthenticatedMode && !sessionQuery.data) { const next = encodeURIComponent(`${location.pathname}${location.search}`); return ; } // Private invitation pages may let signed-in nonmembers request access. // Their token APIs still enforce membership before granting any authority. if (!hasBoardAccess) { return ; } // Outages after the board opened are shown by the app-level // `ConnectionStatusBanner`, not here. return ; }