import { useEffect, useRef, 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 { healthApi } from "@/api/health";
import { isTemporaryApiError } from "@/api/response";
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 { clearCloudSignInAttempt } from "@/lib/cloud-sign-in";
const RECONNECT_INTERVAL_MS = 5_000;
export function CloudAccessError({
temporary,
retrying,
onRetry,
}: {
temporary: boolean;
retrying: boolean;
onRetry: () => void;
}) {
return (
{temporary ? "Reconnecting to Paperclip" : "Unable to load Paperclip"}
{temporary
? "The server may be restarting or your connection was interrupted. We’ll try again every few seconds and reconnect automatically."
: "We couldn’t check your access to this instance. Try again, or contact your instance administrator if this continues."}
{retrying ? "Connecting…" : "Try again"}
);
}
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 [hasOpenedBoard, setHasOpenedBoard] = useState(false);
const healthQuery = useQuery({
queryKey: queryKeys.health,
queryFn: () => healthApi.get(),
retry: false,
refetchInterval: (query) => {
if (query.state.error) return isTemporaryApiError(query.state.error) ? RECONNECT_INTERVAL_MS : false;
const data = query.state.data;
if (data?.status === "starting") return RECONNECT_INTERVAL_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: false,
refetchInterval: (query) => isTemporaryApiError(query.state.error) ? RECONNECT_INTERVAL_MS : false,
refetchIntervalInBackground: true,
});
useEffect(() => {
if (sessionQuery.data) clearCloudSignInAttempt();
}, [sessionQuery.data]);
const boardAccessQuery = useQuery({
queryKey: queryKeys.access.currentBoardAccess,
queryFn: () => accessApi.getCurrentBoardAccess(),
enabled: isAuthenticatedMode && !isBootstrapPending && !!sessionQuery.data,
retry: false,
refetchInterval: (query) => isTemporaryApiError(query.state.error) ? RECONNECT_INTERVAL_MS : false,
refetchIntervalInBackground: true,
});
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";
const isReconnecting = isServerStarting || activeQueries.some((query) => isTemporaryApiError(query.error));
// 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
&& !(query.data !== undefined && isTemporaryApiError(query.error)))?.error;
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]);
const wasReconnecting = useRef(false);
useEffect(() => {
if (isReconnecting) {
wasReconnecting.current = true;
} else if (wasReconnecting.current && !isLoading && !blockingError) {
wasReconnecting.current = false;
// Other reads (including the company list) may have failed during boot.
// Refresh those too so recovery does not strand the user on a second error.
void queryClient.invalidateQueries({ predicate: (query) => isTemporaryApiError(query.state.error) });
}
}, [isReconnecting, isLoading, blockingError, queryClient]);
if (blockingError || (isServerStarting && !hasOpenedBoard)) {
return (
query.isFetching)}
onRetry={() => {
for (const query of activeQueries) {
if (query.error || (query === healthQuery && isServerStarting)) {
void query.refetch({ cancelRefetch: false });
}
}
}}
/>
);
}
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 ;
}
return (
<>
{isReconnecting && (
Connection interrupted. Reconnecting automatically…
)}
>
);
}