UNPKG

@coder/backstage-plugin-coder

Version:

Create and manage Coder workspaces from Backstage

378 lines (375 loc) 13.5 kB
import { jsx, jsxs, Fragment } from 'react/jsx-runtime'; import { createContext, useContext, useEffect, useState, useCallback, useRef, useLayoutEffect } from 'react'; import { createPortal } from 'react-dom'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useApi } from '@backstage/core-plugin-api'; import { makeStyles } from '@material-ui/core'; import { useId } from '../../hooks/hookPolyfills.esm.js'; import { BackstageHttpError } from '../../api/errors.esm.js'; import { sharedAuthQueryKey, CODER_QUERY_KEY_PREFIX } from '../../api/queryOptions.esm.js'; import { coderClientWrapperApiRef } from '../../api/CoderClient.esm.js'; import { coderAuthApiRef } from '../../api/CoderAuthApi.esm.js'; import { CoderLogo } from '../CoderLogo/CoderLogo.esm.js'; import { CoderAuthFormDialog } from '../CoderAuthFormDialog/CoderAuthFormDialog.esm.js'; const BACKSTAGE_APP_ROOT_ID = "#root"; const FALLBACK_UI_OVERRIDE_CLASS_NAME = "backstage-root-override"; const AUTH_GRACE_PERIOD_TIMEOUT_MS = 6e3; const AuthTrackingContext = createContext(null); const AuthStateContext = createContext(null); const validAuthStatuses = [ "authenticated", "distrustedWithGracePeriod" ]; function useAuthState() { const coderClient = useApi(coderClientWrapperApiRef); const coderAuthApi = useApi(coderAuthApiRef); const [authToken, setAuthToken] = useState(""); const [readonlyInitialAuthToken, setReadonlyInitialAuthToken] = useState(""); const [isInsideGracePeriod, setIsInsideGracePeriod] = useState(true); useEffect(() => { let isMounted = true; const loadTokenFromSession = async () => { try { const accessToken = await coderAuthApi.getAccessToken("", { optional: true }); if (isMounted && accessToken) { coderClient.setToken(accessToken); setAuthToken(accessToken); setReadonlyInitialAuthToken(accessToken); } } catch (error) { } }; loadTokenFromSession(); return () => { isMounted = false; }; }, [coderAuthApi, coderClient]); const queryIsEnabled = authToken !== ""; const authValidityQuery = useQuery({ queryKey: [...sharedAuthQueryKey, authToken], queryFn: () => coderClient.syncToken(authToken), enabled: queryIsEnabled, keepPreviousData: queryIsEnabled, // Can't use !query.state.data because we want to refetch on undefined cases refetchOnWindowFocus: (query) => query.state.data !== false }); const authState = generateAuthState({ authToken, authValidityQuery, isInsideGracePeriod, initialAuthToken: readonlyInitialAuthToken }); if (!isInsideGracePeriod && authState.status === "authenticated") { setIsInsideGracePeriod(true); } useEffect(() => { if (authState.status !== "distrustedWithGracePeriod") { return void 0; } const distrustTimeoutId = window.setTimeout(() => { setIsInsideGracePeriod(false); }, AUTH_GRACE_PERIOD_TIMEOUT_MS); return () => window.clearTimeout(distrustTimeoutId); }, [authState.status]); const isAuthenticated = validAuthStatuses.includes(authState.status); const queryClient = useQueryClient(); useEffect(() => { if (!isAuthenticated) { return void 0; } let isRevalidating = false; const revalidateTokenOnError = async (event) => { const queryError = event.query.state.error; const shouldRevalidate = isAuthenticated && !isRevalidating && BackstageHttpError.isInstance(queryError) && queryError.status === 401; if (!shouldRevalidate) { return; } isRevalidating = true; await queryClient.refetchQueries({ queryKey: sharedAuthQueryKey }); isRevalidating = false; }; const queryCache = queryClient.getQueryCache(); const unsubscribe = queryCache.subscribe(revalidateTokenOnError); return unsubscribe; }, [queryClient, isAuthenticated]); const registerNewToken = useCallback((newToken) => { if (newToken !== "") { setAuthToken(newToken); } }, []); const unlinkToken = useCallback(() => { setAuthToken(""); coderAuthApi.signOut().catch(() => { }); queryClient.removeQueries({ queryKey: [CODER_QUERY_KEY_PREFIX] }); }, [queryClient, coderAuthApi]); return { ...authState, isAuthenticated, registerNewToken, unlinkToken }; } function useAuthFallbackState() { const [isMounted, setIsMounted] = useState(false); useEffect(() => { setIsMounted(true); }, []); const [hasTrackers, setHasTrackers] = useState(false); const trackedComponentsRef = useRef(null); if (trackedComponentsRef.current === null) { trackedComponentsRef.current = /* @__PURE__ */ new Set(); } const trackComponent = useCallback((componentId) => { const syncTrackerToUi = () => { setHasTrackers(trackedComponentsRef.current.size > 0); }; trackedComponentsRef.current.add(componentId); syncTrackerToUi(); return () => { trackedComponentsRef.current.delete(componentId); syncTrackerToUi(); }; }, []); return { trackComponent, hasNoAuthInputs: isMounted && !hasTrackers }; } function useInternalCoderAuth() { const trackComponent = useContext(AuthTrackingContext); if (trackComponent === null) { throw new Error("Unable to retrieve state for displaying fallback auth UI"); } const instanceId = useId(); useEffect(() => { const cleanupTracking = trackComponent(instanceId); return cleanupTracking; }, [instanceId, trackComponent]); return useEndUserCoderAuth(); } function useEndUserCoderAuth() { const authContextValue = useContext(AuthStateContext); if (authContextValue === null) { throw new Error("Cannot retrieve auth information from CoderProvider"); } return authContextValue; } function generateAuthState({ authToken, initialAuthToken, authValidityQuery, isInsideGracePeriod }) { const isInitializing = initialAuthToken !== "" && authValidityQuery.isLoading && authValidityQuery.isFetching && !authValidityQuery.isFetchedAfterMount; if (isInitializing) { return { status: "initializing", token: void 0, error: void 0 }; } if (!authToken) { return { status: "tokenMissing", token: void 0, error: void 0 }; } if (BackstageHttpError.isInstance(authValidityQuery.error)) { const deploymentLikelyUnavailable = authValidityQuery.error.status === 504 || authValidityQuery.error.status === 200 && authValidityQuery.error.contentType !== "application/json"; if (deploymentLikelyUnavailable) { return { status: "deploymentUnavailable", token: void 0, error: authValidityQuery.error }; } } const isTokenValidFromPrevFetch = authValidityQuery.data === true; if (isTokenValidFromPrevFetch) { const canTrustAuthThisRender = authValidityQuery.isSuccess && !authValidityQuery.isPaused; if (canTrustAuthThisRender) { return { status: "authenticated", token: authToken, error: void 0 }; } if (isInsideGracePeriod) { return { status: "distrustedWithGracePeriod", token: authToken, error: void 0 }; } return { status: "distrusted", token: void 0, error: authValidityQuery.error }; } const isAuthenticating = authValidityQuery.isLoading || authValidityQuery.isRefetching && (authValidityQuery.isError && authValidityQuery.data !== true || authValidityQuery.isSuccess && authValidityQuery.data === false); if (isAuthenticating) { return { status: "authenticating", token: void 0, error: authValidityQuery.error }; } const isCoderDeploymentDown = authValidityQuery.error instanceof Error && authValidityQuery.error.name === "TimeoutError"; if (isCoderDeploymentDown) { return { status: "distrusted", token: void 0, error: authValidityQuery.error }; } if (authValidityQuery.isPaused) { return { status: "noInternetConnection", token: void 0, error: authValidityQuery.error }; } return { status: "invalid", token: void 0, error: authValidityQuery.error }; } const mainAppRoot = document.querySelector(BACKSTAGE_APP_ROOT_ID); const useFallbackStyles = makeStyles((theme) => ({ landmarkWrapper: ({ isDialogOpen }) => ({ zIndex: isDialogOpen ? 0 : 9999, position: "fixed", bottom: theme.spacing(2), width: "100%", maxWidth: "fit-content", left: "50%", transform: "translateX(-50%)" }), dialogButton: { display: "flex", flexFlow: "row nowrap", columnGap: theme.spacing(1), alignItems: "center" }, logo: { fill: theme.palette.primary.contrastText, width: theme.spacing(3) } })); function FallbackAuthUi() { const fallbackRef = useRef(null); useLayoutEffect(() => { const fallback = fallbackRef.current; const mainAppContainer = mainAppRoot?.querySelector("main") ?? null; if (fallback === null || mainAppContainer === null) { return void 0; } const overrideStyleNode = document.createElement("style"); overrideStyleNode.type = "text/css"; const liveAppStyles = getComputedStyle(mainAppContainer); const liveFallbackStyles = getComputedStyle(fallback); let prevPaddingBottom = void 0; const updatePaddingForFallbackUi = () => { const prevInnerHtml = overrideStyleNode.innerHTML; overrideStyleNode.innerHTML = ""; const paddingBottomWithNoOverride = liveAppStyles.paddingBottom || "0px"; if (paddingBottomWithNoOverride === prevPaddingBottom) { overrideStyleNode.innerHTML = prevInnerHtml; return; } const fallbackBottom = parseInt(liveFallbackStyles.bottom || "0", 10); const normalized = Number.isNaN(fallbackBottom) ? 0 : fallbackBottom; const paddingToAdd = fallback.offsetHeight + normalized; overrideStyleNode.innerHTML = ` .${FALLBACK_UI_OVERRIDE_CLASS_NAME} { padding-bottom: calc(${paddingBottomWithNoOverride} + ${paddingToAdd}px) !important; } `; prevPaddingBottom = paddingBottomWithNoOverride; }; const observer = new MutationObserver(updatePaddingForFallbackUi); observer.observe(document.head, { childList: true }); observer.observe(mainAppContainer, { childList: false, subtree: false, attributes: true, attributeFilter: ["class", "style"] }); document.head.append(overrideStyleNode); mainAppContainer.classList.add(FALLBACK_UI_OVERRIDE_CLASS_NAME); return () => { observer.disconnect(); overrideStyleNode.remove(); mainAppContainer.classList.remove(FALLBACK_UI_OVERRIDE_CLASS_NAME); }; }, []); const hookId = useId(); const [isDialogOpen, setIsDialogOpen] = useState(false); const styles = useFallbackStyles({ isDialogOpen }); const landmarkId = `${hookId}-landmark`; const fallbackUi = /* @__PURE__ */ jsxs( "section", { ref: fallbackRef, className: styles.landmarkWrapper, "aria-labelledby": landmarkId, children: [ /* @__PURE__ */ jsx("h2", { id: landmarkId, hidden: true, children: "Authenticate with Coder to enable Coder plugin features" }), /* @__PURE__ */ jsxs( CoderAuthFormDialog, { open: isDialogOpen, onOpen: () => setIsDialogOpen(true), onClose: () => setIsDialogOpen(false), triggerClassName: styles.dialogButton, children: [ /* @__PURE__ */ jsx(CoderLogo, { className: styles.logo }), "Authenticate with Coder" ] } ) ] } ); return createPortal(fallbackUi, document.body); } const dummyTrackComponent = () => { return () => { }; }; const fallbackProviders = { hidden: ({ children }) => /* @__PURE__ */ jsx(AuthTrackingContext.Provider, { value: dummyTrackComponent, children }), assertive: ({ children, isAuthenticated }) => ( // Don't need the live version of the tracker function if we're always // going to be showing the fallback auth input no matter what /* @__PURE__ */ jsxs(AuthTrackingContext.Provider, { value: dummyTrackComponent, children: [ children, !isAuthenticated && /* @__PURE__ */ jsx(FallbackAuthUi, {}) ] }) ), // Have to give function a name to satisfy ES Lint (rules of hooks) restrained: function Restrained({ children, isAuthenticated }) { const { hasNoAuthInputs, trackComponent } = useAuthFallbackState(); const needFallbackUi = !isAuthenticated && hasNoAuthInputs; return /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx(AuthTrackingContext.Provider, { value: trackComponent, children }), needFallbackUi && /* @__PURE__ */ jsx(AuthTrackingContext.Provider, { value: dummyTrackComponent, children: /* @__PURE__ */ jsx(FallbackAuthUi, {}) }) ] }); } }; function CoderAuthProvider({ children, fallbackAuthUiMode = "restrained" }) { const authState = useAuthState(); const AuthFallbackProvider = fallbackProviders[fallbackAuthUiMode]; return /* @__PURE__ */ jsx(AuthStateContext.Provider, { value: authState, children: /* @__PURE__ */ jsx(AuthFallbackProvider, { isAuthenticated: authState.isAuthenticated, children }) }); } export { AuthStateContext, AuthTrackingContext, CoderAuthProvider, dummyTrackComponent, useEndUserCoderAuth, useInternalCoderAuth }; //# sourceMappingURL=CoderAuthProvider.esm.js.map