diff --git a/plugins/backstage-plugin-coder/package.json b/plugins/backstage-plugin-coder/package.json index e48c8f21..846a65b8 100644 --- a/plugins/backstage-plugin-coder/package.json +++ b/plugins/backstage-plugin-coder/package.json @@ -41,6 +41,10 @@ "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.61", "@tanstack/react-query": "4.36.1", + "axios": "^1.6.8", + "dayjs": "^1.11.10", + "ua-parser-js": "^1.0.37", + "use-sync-external-store": "^1.2.0", "valibot": "^0.28.1" }, "peerDependencies": { @@ -54,6 +58,7 @@ "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^14.2.1", "@testing-library/user-event": "^14.0.0", + "@types/ua-parser-js": "^0.7.39", "msw": "^1.0.0" }, "files": [ diff --git a/plugins/backstage-plugin-coder/src/api.ts b/plugins/backstage-plugin-coder/src/api.ts deleted file mode 100644 index d11248eb..00000000 --- a/plugins/backstage-plugin-coder/src/api.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { parse } from 'valibot'; -import { type UseQueryOptions } from '@tanstack/react-query'; - -import { CoderWorkspacesConfig } from './hooks/useCoderWorkspacesConfig'; -import { - type Workspace, - workspaceBuildParametersSchema, - workspacesResponseSchema, - WorkspaceAgentStatus, -} from './typesConstants'; -import { CoderAuth, assertValidCoderAuth } from './components/CoderProvider'; -import { IdentityApi } from '@backstage/core-plugin-api'; - -export const CODER_QUERY_KEY_PREFIX = 'coder-backstage-plugin'; - -const PROXY_ROUTE_PREFIX = '/api/proxy/coder'; -export const API_ROUTE_PREFIX = `${PROXY_ROUTE_PREFIX}/api/v2`; -export const ASSETS_ROUTE_PREFIX = PROXY_ROUTE_PREFIX; - -export const CODER_AUTH_HEADER_KEY = 'Coder-Session-Token'; -export const REQUEST_TIMEOUT_MS = 20_000; - -async function getCoderApiRequestInit( - authToken: string, - identity: IdentityApi, -): Promise { - const headers: HeadersInit = { - [CODER_AUTH_HEADER_KEY]: authToken, - }; - - try { - const credentials = await identity.getCredentials(); - if (credentials.token) { - headers.Authorization = `Bearer ${credentials.token}`; - } - } catch (err) { - if (err instanceof Error) { - throw err; - } - - throw new Error( - "Unable to parse user information for Coder requests. Please ensure that your Backstage deployment is integrated to use Backstage's Identity API", - ); - } - - return { - headers, - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }; -} - -// Makes it easier to expose HTTP responses in the event of errors and also -// gives TypeScript a faster way to type-narrow on those errors -export class BackstageHttpError extends Error { - #response: Response; - - constructor(errorMessage: string, response: Response) { - super(errorMessage); - this.name = 'HttpError'; - this.#response = response; - } - - get status() { - return this.#response.status; - } - - get ok() { - return this.#response.ok; - } - - get contentType() { - return this.#response.headers.get('content_type'); - } -} - -type FetchInputs = Readonly<{ - auth: CoderAuth; - baseUrl: string; - identity: IdentityApi; -}>; - -type WorkspacesFetchInputs = Readonly< - FetchInputs & { - coderQuery: string; - } ->; - -async function getWorkspaces( - fetchInputs: WorkspacesFetchInputs, -): Promise { - const { baseUrl, coderQuery, auth, identity } = fetchInputs; - assertValidCoderAuth(auth); - - const urlParams = new URLSearchParams({ - q: coderQuery, - limit: '0', - }); - - const requestInit = await getCoderApiRequestInit(auth.token, identity); - const response = await fetch( - `${baseUrl}${API_ROUTE_PREFIX}/workspaces?${urlParams.toString()}`, - requestInit, - ); - - if (!response.ok) { - throw new BackstageHttpError( - `Unable to retrieve workspaces for query (${coderQuery})`, - response, - ); - } - - if (!response.headers.get('content-type')?.includes('application/json')) { - throw new BackstageHttpError( - '200 request has no data - potential proxy issue', - response, - ); - } - - const json = await response.json(); - const { workspaces } = parse(workspacesResponseSchema, json); - - const withRemappedImgUrls = workspaces.map(ws => { - const templateIcon = ws.template_icon; - if (!templateIcon.startsWith('/')) { - return ws; - } - - return { - ...ws, - template_icon: `${baseUrl}${ASSETS_ROUTE_PREFIX}${templateIcon}`, - }; - }); - - return withRemappedImgUrls; -} - -type BuildParamsFetchInputs = Readonly< - FetchInputs & { - workspaceBuildId: string; - } ->; - -async function getWorkspaceBuildParameters(inputs: BuildParamsFetchInputs) { - const { baseUrl, auth, workspaceBuildId, identity } = inputs; - assertValidCoderAuth(auth); - - const requestInit = await getCoderApiRequestInit(auth.token, identity); - const res = await fetch( - `${baseUrl}${API_ROUTE_PREFIX}/workspacebuilds/${workspaceBuildId}/parameters`, - requestInit, - ); - - if (!res.ok) { - throw new BackstageHttpError( - `Failed to retreive build params for workspace ID ${workspaceBuildId}`, - res, - ); - } - - if (!res.headers.get('content-type')?.includes('application/json')) { - throw new BackstageHttpError( - '200 request has no data - potential proxy issue', - res, - ); - } - - const json = await res.json(); - return parse(workspaceBuildParametersSchema, json); -} - -type WorkspacesByRepoFetchInputs = Readonly< - WorkspacesFetchInputs & { - workspacesConfig: CoderWorkspacesConfig; - } ->; - -export async function getWorkspacesByRepo( - inputs: WorkspacesByRepoFetchInputs, -): Promise { - const workspaces = await getWorkspaces(inputs); - - const paramResults = await Promise.allSettled( - workspaces.map(ws => - getWorkspaceBuildParameters({ - ...inputs, - workspaceBuildId: ws.latest_build.id, - }), - ), - ); - - const { workspacesConfig } = inputs; - const matchedWorkspaces: Workspace[] = []; - - for (const [index, res] of paramResults.entries()) { - if (res.status === 'rejected') { - continue; - } - - for (const param of res.value) { - const include = - workspacesConfig.repoUrlParamKeys.includes(param.name) && - param.value === workspacesConfig.repoUrl; - - if (include) { - // Doing type assertion just in case noUncheckedIndexedAccess compiler - // setting ever gets turned on; this shouldn't ever break, but it's - // technically not type-safe - matchedWorkspaces.push(workspaces[index] as Workspace); - break; - } - } - } - - return matchedWorkspaces; -} - -export function getWorkspaceAgentStatuses( - workspace: Workspace, -): readonly WorkspaceAgentStatus[] { - const uniqueStatuses: WorkspaceAgentStatus[] = []; - - for (const resource of workspace.latest_build.resources) { - if (resource.agents === undefined) { - continue; - } - - for (const agent of resource.agents) { - const status = agent.status; - if (!uniqueStatuses.includes(status)) { - uniqueStatuses.push(status); - } - } - } - - return uniqueStatuses; -} - -export function isWorkspaceOnline(workspace: Workspace): boolean { - const latestBuildStatus = workspace.latest_build.status; - const isAvailable = - latestBuildStatus !== 'stopped' && - latestBuildStatus !== 'stopping' && - latestBuildStatus !== 'pending'; - - if (!isAvailable) { - return false; - } - - const statuses = getWorkspaceAgentStatuses(workspace); - return statuses.every( - status => status === 'connected' || status === 'connecting', - ); -} - -export function workspaces( - inputs: WorkspacesFetchInputs, -): UseQueryOptions { - const enabled = inputs.auth.status === 'authenticated'; - - return { - queryKey: [CODER_QUERY_KEY_PREFIX, 'workspaces', inputs.coderQuery], - queryFn: () => getWorkspaces(inputs), - enabled, - keepPreviousData: enabled && inputs.coderQuery !== '', - }; -} - -export function workspacesByRepo( - inputs: WorkspacesByRepoFetchInputs, -): UseQueryOptions { - const enabled = - inputs.auth.status === 'authenticated' && inputs.coderQuery !== ''; - - return { - queryKey: [CODER_QUERY_KEY_PREFIX, 'workspaces', inputs.coderQuery, 'repo'], - queryFn: () => getWorkspacesByRepo(inputs), - enabled, - keepPreviousData: enabled, - }; -} - -type AuthValidationInputs = Readonly<{ - baseUrl: string; - authToken: string; - identity: IdentityApi; -}>; - -async function isAuthValid(inputs: AuthValidationInputs): Promise { - const { baseUrl, authToken, identity } = inputs; - - // In this case, the request doesn't actually matter. Just need to make any - // kind of dummy request to validate the auth - const requestInit = await getCoderApiRequestInit(authToken, identity); - const response = await fetch( - `${baseUrl}${API_ROUTE_PREFIX}/users/me`, - requestInit, - ); - - if (response.status >= 400 && response.status !== 401) { - throw new BackstageHttpError('Failed to complete request', response); - } - - return response.status !== 401; -} - -export const authQueryKey = [CODER_QUERY_KEY_PREFIX, 'auth'] as const; - -export function authValidation( - inputs: AuthValidationInputs, -): UseQueryOptions { - const enabled = inputs.authToken !== ''; - return { - queryKey: [...authQueryKey, inputs.authToken], - queryFn: () => isAuthValid(inputs), - enabled, - keepPreviousData: enabled, - }; -} diff --git a/plugins/backstage-plugin-coder/src/api/Auth.ts b/plugins/backstage-plugin-coder/src/api/Auth.ts new file mode 100644 index 00000000..4ae5a1f2 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/Auth.ts @@ -0,0 +1,60 @@ +/** + * @file Defines shared values and types used among any custom Coder auth + * implementations for the frontend. + */ +import { createApiRef } from '@backstage/core-plugin-api'; +import { CODER_API_REF_ID_PREFIX } from '../typesConstants'; + +type AuthSubscriptionPayload = Readonly<{ + token: string; + isTokenValid: boolean; +}>; + +export type AuthSubscriptionCallback< + TSubscriptionPayload extends AuthSubscriptionPayload = AuthSubscriptionPayload, +> = (payload: TSubscriptionPayload) => void; + +export type AuthValidatorDispatch = (newStatus: boolean) => void; + +/** + * Shared set of properties among all Coder auth implementations + */ +export type CoderAuthApi< + TPayload extends AuthSubscriptionPayload = AuthSubscriptionPayload, +> = TPayload & { + /** + * Gives back a "state setter" that lets a different class dispatch a new auth + * status to the auth class implementation. + * + * Use this to send the new status you think the auth should have. The auth + * will decide whether it will let the dispatch go through and update state. + */ + getAuthStateSetter: () => AuthValidatorDispatch; + + /** + * Subscribes an external system to auth changes. + * + * Returns an pre-wired unsubscribe callback to remove fuss of needing to hold + * onto the original callback if it's not directly needed anymore + */ + subscribe: (callback: AuthSubscriptionCallback) => () => void; + + /** + * Lets an external system unsubscribe from auth changes. + */ + unsubscribe: (callback: AuthSubscriptionCallback) => void; + + /** + * Lets an external system get a fully immutable snapshot of the current auth + * state. + */ + getStateSnapshot: () => AuthSubscriptionPayload; +}; + +/** + * A single, shared auth API ref that can be used with any of the CoderAuth + * API classes (CoderTokenAuth, eventually CoderOAuth, etc.) + */ +export const coderAuthApiRef = createApiRef({ + id: `${CODER_API_REF_ID_PREFIX}.auth`, +}); diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.test.tsx b/plugins/backstage-plugin-coder/src/api/CoderClient.test.tsx new file mode 100644 index 00000000..2b66d17f --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.test.tsx @@ -0,0 +1,152 @@ +import React, { useEffect } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; +import { act, render, waitFor } from '@testing-library/react'; +import { + getMockDiscoveryApi, + getMockIdentityApi, + mockCoderAuthToken, + setupCoderClient, +} from '../testHelpers/mockBackstageData'; +import { CoderClient, CoderClientSnapshot } from './CoderClient'; +import { CoderTokenAuth } from './CoderTokenAuth'; + +type TokenAuthSetupOutput = Readonly<{ + coderClientApi: CoderClient; + authApi: CoderTokenAuth; +}>; + +function setupCoderClientWithTokenAuth(): TokenAuthSetupOutput { + const authApi = new CoderTokenAuth(); + const discoveryApi = getMockDiscoveryApi(); + const identityApi = getMockIdentityApi(); + + const { coderClientApi } = setupCoderClient({ + discoveryApi, + identityApi, + authApi, + }); + + return { authApi, coderClientApi }; +} + +/** + * @todo Decide if we want to test the SDK-like functionality (even as a + * stopgap). Once we can import the methods from Coder, it might be safe for the + * plugin to assume the methods will always work. + * + * Plus, the other test files making requests to the SDK to get specific data + * should kick up any other issues. + */ +describe(`${CoderClient.name}`, () => { + /** + * Once the OAuth implementation is done, it probably makes sense to have test + * cases specifically for that. + */ + describe('With token auth', () => { + describe('validateAuth method', () => { + it('Will update the underlying auth instance when a query succeeds', async () => { + const { coderClientApi, authApi } = setupCoderClientWithTokenAuth(); + + authApi.registerNewToken(mockCoderAuthToken); + const validationResult = await coderClientApi.validateAuth(); + + expect(validationResult).toBe(true); + expect(authApi.isTokenValid).toBe(true); + + const clientSnapshot = coderClientApi.getStateSnapshot(); + expect(clientSnapshot).toEqual( + expect.objectContaining>({ + isAuthValid: true, + }), + ); + }); + + it('Will update the underlying auth instance when a query fails', async () => { + const { coderClientApi, authApi } = setupCoderClientWithTokenAuth(); + + authApi.registerNewToken('Definitely not a valid token'); + const validationResult = await coderClientApi.validateAuth(); + + expect(validationResult).toBe(false); + expect(authApi.isTokenValid).toBe(false); + + const clientSnapshot = coderClientApi.getStateSnapshot(); + expect(clientSnapshot).toEqual( + expect.objectContaining>({ + isAuthValid: false, + }), + ); + }); + }); + }); + + describe('State snapshot subscriptions', () => { + it('Lets external systems subscribe to state changes', async () => { + const { coderClientApi } = setupCoderClientWithTokenAuth(); + const onChange = jest.fn(); + coderClientApi.subscribe(onChange); + + await coderClientApi.validateAuth(); + expect(onChange).toHaveBeenCalled(); + }); + + it('Lets external systems UN-subscribe to state changes', async () => { + const { coderClientApi } = setupCoderClientWithTokenAuth(); + const subscriber1 = jest.fn(); + const subscriber2 = jest.fn(); + + /** + * Doing something a little sneaky to try accounting for something that + * could happen in the real world. The setup is: + * + * 1. External system subscribes to client + * 2. Client calls validateAuth, which is async and goes through the + * microtask queue + * 3. During that brief window where we're waiting for the response to + * come back, the external system unsubscribes + * 4. Promise resolves, and the auth state changes, but the old subscriber + * should *NOT* get notified because it's unsubscribed now + */ + coderClientApi.subscribe(subscriber1); + coderClientApi.subscribe(subscriber2); + + // Important that there's no await here. Do not want to pause the thread + // of execution until after subscriber2 unsubscribes. + void coderClientApi.validateAuth(); + coderClientApi.unsubscribe(subscriber2); + + await waitFor(() => expect(subscriber1).toHaveBeenCalled()); + expect(subscriber2).not.toHaveBeenCalled(); + }); + + it('Provides tools to let React components bind re-renders to state changes', async () => { + const { coderClientApi } = setupCoderClientWithTokenAuth(); + const onStateChange = jest.fn(); + + const DummyReactComponent = () => { + const reactiveStateSnapshot = useSyncExternalStore( + coderClientApi.subscribe, + coderClientApi.getStateSnapshot, + ); + + useEffect(() => { + onStateChange(); + }, [reactiveStateSnapshot]); + + return null; + }; + + const { rerender } = render(); + expect(onStateChange).toHaveBeenCalledTimes(1); + + await act(() => coderClientApi.validateAuth()); + expect(onStateChange).toHaveBeenCalledTimes(2); + + // Make sure that if the component re-renders from the top down (like a + // parent state change), that does not cause the snapshot to lose its + // stable reference + rerender(); + expect(onStateChange).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts new file mode 100644 index 00000000..996ecc39 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -0,0 +1,417 @@ +/** + * @file This class is a little chaotic. It's basically in charge of juggling + * and coordinating several different systems together: + * + * 1. Backstage APIs (API classes/factories, as well as proxies) + * 2. React (making sure that mutable class state can be turned into immutable + * state snapshots that are available synchronously from the first render) + * 3. The custom auth API(s) that we build out for Backstage + * 4. The Coder SDK (either the eventual real one, or the fake stopgap) + * 5. Axios (which we need, because it's what the Coder SDK uses) + * + * All while being easy for the end-user to drop into their own Backstage + * deployment. + */ +import globalAxios, { + type InternalAxiosRequestConfig, + AxiosError, +} from 'axios'; +import { + type DiscoveryApi, + type IdentityApi, + createApiRef, +} from '@backstage/core-plugin-api'; +import { BackstageHttpError } from './errors'; + +import type { CoderAuthApi } from './Auth'; +import { + CODER_API_REF_ID_PREFIX, + Workspace, + WorkspaceBuildParameter, +} from '../typesConstants'; +import { StateSnapshotManager } from '../utils/StateSnapshotManager'; +import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; + +type CoderClientConfigOptions = Readonly<{ + proxyPrefix: string; + apiRoutePrefix: string; + authHeaderKey: string; + assetsRoutePrefix: string; + requestTimeoutMs: number; +}>; + +export const defaultCoderClientConfigOptions = { + proxyPrefix: '/coder', + apiRoutePrefix: '/api/v2', + assetsRoutePrefix: '/', // Deliberately left as single slash + authHeaderKey: 'Coder-Session-Token', + requestTimeoutMs: 20_000, +} as const satisfies CoderClientConfigOptions; + +export type CoderClientSnapshot = Readonly<{ + isAuthValid: boolean; + apiRoute: string; + assetsRoute: string; +}>; + +/** + * @todo Replace these type definitions with the full Coder SDK API once we have + * that built out and ready to import into other projects. Be sure to export out + * all type definitions from the API under a single namespace, too. (e.g., + * export type * as CoderSdkTypes from 'coder-ts-sdk') + * + * The types for RawCoderSdkApi should only include functions/values that exist + * on the current "pseudo-SDK" found in the main coder/coder repo, and that are + * likely to carry over to the full SDK. + * + * @see {@link https://github.com/coder/coder/tree/main/site/src/api} + */ +type WorkspacesRequest = Readonly<{ + after_id?: string; + limit?: number; + offset?: number; + q?: string; +}>; + +type WorkspacesResponse = Readonly<{ + workspaces: readonly Workspace[]; + count: number; +}>; + +export type UserLoginType = Readonly<{ + login_type: '' | 'github' | 'none' | 'oidc' | 'password' | 'token'; +}>; + +/** + * @todo This should eventually be the real Coder SDK. + */ +type RawCoderSdkApi = { + getUserLoginType: () => Promise; + getWorkspaces: (options: WorkspacesRequest) => Promise; + getWorkspaceBuildParameters: ( + input: string, + ) => Promise; +}; + +/** + * A version of the main Coder SDK API, with additional Backstage-specific + * methods and properties. + */ +export type BackstageCoderSdkApi = Readonly< + RawCoderSdkApi & { + getWorkspacesByRepo: ( + coderQuery: string, + config: CoderWorkspacesConfig, + ) => Promise; + } +>; + +type SubscriptionCallback = (newSnapshot: CoderClientSnapshot) => void; + +export type CoderClientApi = Readonly<{ + sdkApi: BackstageCoderSdkApi; + isAuthValid: boolean; + validateAuth: () => Promise; + + getStateSnapshot: () => CoderClientSnapshot; + unsubscribe: (callback: SubscriptionCallback) => void; + subscribe: (callback: SubscriptionCallback) => () => void; + cleanupClient: () => void; +}>; + +/** + * @todo Using an Axios instance to ensure that even if another user is using + * Axios, there's no risk of our request intercepting logic messing up non-Coder + * requests. + * + * However, the current version of the SDK does NOT have this behavior. Make + * sure that it does when it finally gets built out. + */ +const axiosInstance = globalAxios.create(); + +type CoderClientConstructorInputs = Partial & { + apis: Readonly<{ + identityApi: IdentityApi; + discoveryApi: DiscoveryApi; + authApi: CoderAuthApi; + }>; +}; + +export class CoderClient implements CoderClientApi { + private readonly identityApi: IdentityApi; + private readonly discoveryApi: DiscoveryApi; + private readonly authApi: CoderAuthApi; + + private readonly options: CoderClientConfigOptions; + private readonly snapshotManager: StateSnapshotManager; + private readonly axiosInterceptorId: number; + private readonly abortController: AbortController; + + private latestProxyEndpoint: string; + readonly sdkApi: BackstageCoderSdkApi; + + /* *************************************************************************** + * There is some funky (but necessary) stuff going on in this class - a lot of + * the class methods are passed directly to other systems. Just to be on the + * safe side, all methods (public and private) should be defined as arrow + * functions, to ensure the methods can't ever lose their `this` contexts + * + * This technically defeats some of the memory optimizations you would + * normally get with class methods (arrow methods will be rebuilt from + * scratch each time the class is instantiated), but because CoderClient will + * likely be instantiated only once for the entire app's lifecycle, that won't + * matter much at all + ****************************************************************************/ + + constructor(inputs: CoderClientConstructorInputs) { + const { apis, ...options } = inputs; + const { discoveryApi, identityApi, authApi } = apis; + + // The "easy setup" part - initialize internal properties + this.identityApi = identityApi; + this.discoveryApi = discoveryApi; + this.authApi = authApi; + this.latestProxyEndpoint = ''; + this.options = { ...defaultCoderClientConfigOptions, ...(options ?? {}) }; + + /** + * Wire up SDK API namespace. + * + * @todo All methods are defined locally in the class, but this should + * eventually be updated so that 99% of methods come from the SDK, with a + * few extra methods patched in for Backstage convenience + */ + this.sdkApi = { + getUserLoginType: this.getUserLoginType, + getWorkspaceBuildParameters: this.getWorkspaceBuildParameters, + getWorkspaces: this.getWorkspaces, + getWorkspacesByRepo: this.getWorkspacesByRepo, + }; + + // Wire up Backstage APIs and Axios to be aware of each other + this.abortController = new AbortController(); + this.axiosInterceptorId = axiosInstance.interceptors.request.use( + this.interceptAxiosRequest, + ); + + // Hook up snapshot manager so that external systems can be made aware when + // state changes, all in a render-safe way + this.snapshotManager = new StateSnapshotManager({ + initialSnapshot: this.prepareNewStateSnapshot(), + }); + + // Set up logic for syncing client snapshots to auth state changes + this.authApi.subscribe(newAuthSnapshot => { + const latestClientSnapshot = this.getStateSnapshot(); + if (newAuthSnapshot.isTokenValid !== latestClientSnapshot.isAuthValid) { + this.notifySubscriptionsOfStateChange(); + } + }); + + // Call DiscoveryApi to populate initial endpoint path, so that the path + // can be accessed synchronously from the UI. Should be called last after + // all other initialization steps + void this.getProxyEndpoint(); + } + + get isAuthValid(): boolean { + return this.authApi.isTokenValid; + } + + // Request configs are created on the per-request basis, so mutating a config + // won't mess up future non-Coder requests that also uses Axios + private interceptAxiosRequest = async ( + config: InternalAxiosRequestConfig, + ): Promise => { + const { authHeaderKey, apiRoutePrefix } = this.options; + + const proxyEndpoint = await this.getProxyEndpoint(); + config.baseURL = `${proxyEndpoint}${apiRoutePrefix}`; + config.headers[authHeaderKey] = this.authApi.token; + config.signal = this.abortController.signal; + + const bearerToken = (await this.identityApi.getCredentials()).token; + if (bearerToken) { + config.headers.Authorization = `Bearer ${bearerToken}`; + } + + return config; + }; + + private prepareNewStateSnapshot = (): CoderClientSnapshot => { + const base = this.latestProxyEndpoint; + const { apiRoutePrefix, assetsRoutePrefix } = this.options; + + return { + isAuthValid: this.authApi.isTokenValid, + apiRoute: `${base}${apiRoutePrefix}`, + assetsRoute: `${base}${assetsRoutePrefix}`, + }; + }; + + private notifySubscriptionsOfStateChange = (): void => { + const newSnapshot = this.prepareNewStateSnapshot(); + this.snapshotManager.updateSnapshot(newSnapshot); + }; + + // Backstage officially recommends that you use the DiscoveryApi over the + // ConfigApi nowadays, and that you call it before each request. But the + // problem is that the Discovery API has no synchronous methods for getting + // endpoints, meaning that there's no great built-in way to access that data + // from the UI's render logic. Have to cache the return value to close the gap + private getProxyEndpoint = async (): Promise => { + const latestBase = await this.discoveryApi.getBaseUrl('proxy'); + const withProxy = `${latestBase}${this.options.proxyPrefix}`; + + this.latestProxyEndpoint = withProxy; + this.notifySubscriptionsOfStateChange(); + + return withProxy; + }; + + private getUserLoginType = async (): Promise => { + const response = await axiosInstance.get( + '/users/me/login-type', + ); + + return response.data; + }; + + private remapWorkspaceIconUrls = ( + workspaces: readonly Workspace[], + ): Workspace[] => { + const { assetsRoute } = this.getStateSnapshot(); + + return workspaces.map(ws => { + const templateIconUrl = ws.template_icon; + if (!templateIconUrl.startsWith('/')) { + return ws; + } + + return { + ...ws, + template_icon: `${assetsRoute}${templateIconUrl}`, + }; + }); + }; + + private getWorkspaceBuildParameters = async ( + workspaceBuildId: string, + ): Promise => { + const response = await axiosInstance.get< + readonly WorkspaceBuildParameter[] + >(`/workspacebuilds/${workspaceBuildId}/parameters`); + + return response.data; + }; + + private getWorkspaces = async ( + options: WorkspacesRequest, + ): Promise => { + const urlParams = new URLSearchParams({ + q: options.q ?? '', + limit: String(options.limit || 0), + }); + + const { data } = await axiosInstance.get( + `/workspaces?${urlParams.toString()}`, + ); + + const remapped: WorkspacesResponse = { + ...data, + workspaces: this.remapWorkspaceIconUrls(data.workspaces), + }; + + return remapped; + }; + + private getWorkspacesByRepo = async ( + coderQuery: string, + config: CoderWorkspacesConfig, + ): Promise => { + const { workspaces } = await this.getWorkspaces({ + q: coderQuery, + limit: 0, + }); + + const remappedWorkspaces = this.remapWorkspaceIconUrls(workspaces); + const paramResults = await Promise.allSettled( + remappedWorkspaces.map(ws => + this.sdkApi.getWorkspaceBuildParameters(ws.latest_build.id), + ), + ); + + const matchedWorkspaces: Workspace[] = []; + for (const [index, res] of paramResults.entries()) { + if (res.status === 'rejected') { + continue; + } + + for (const param of res.value) { + const include = + config.repoUrlParamKeys.includes(param.name) && + param.value === config.repoUrl; + + if (include) { + // Doing type assertion just in case noUncheckedIndexedAccess compiler + // setting ever gets turned on; this shouldn't ever break, but it's + // technically not type-safe + matchedWorkspaces.push(workspaces[index] as Workspace); + break; + } + } + } + + return matchedWorkspaces; + }; + + unsubscribe = (callback: SubscriptionCallback): void => { + this.snapshotManager.unsubscribe(callback); + }; + + subscribe = (callback: SubscriptionCallback): (() => void) => { + return this.snapshotManager.subscribe(callback); + }; + + getStateSnapshot = (): CoderClientSnapshot => { + return this.snapshotManager.getSnapshot(); + }; + + validateAuth = async (): Promise => { + const dispatchNewStatus = this.authApi.getAuthStateSetter(); + + try { + // Dummy request; just need something that all users would have access + // to, and that doesn't require a body + await this.sdkApi.getUserLoginType(); + dispatchNewStatus(true); + return true; + } catch (err) { + dispatchNewStatus(false); + + if (!(err instanceof AxiosError)) { + throw err; + } + + const response = err.response; + if (response === undefined) { + throw new Error('Unable to complete request - unknown error detected.'); + } + + if (response.status >= 400 && response.status !== 401) { + throw new BackstageHttpError('Failed to complete request', response); + } + } + + return false; + }; + + cleanupClient = () => { + this.abortController.abort(); + axiosInstance.interceptors.request.eject(this.axiosInterceptorId); + }; +} + +export const coderClientApiRef = createApiRef({ + id: `${CODER_API_REF_ID_PREFIX}.coder-client`, +}); diff --git a/plugins/backstage-plugin-coder/src/api/CoderTokenAuth.test.ts b/plugins/backstage-plugin-coder/src/api/CoderTokenAuth.test.ts new file mode 100644 index 00000000..c1f79f3d --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/CoderTokenAuth.test.ts @@ -0,0 +1,254 @@ +import { getMockLocalStorage } from '../testHelpers/mockBackstageData'; +import { + type AuthTokenStateSnapshot, + CoderTokenAuth, + AUTH_SETTER_TIMEOUT_MS, +} from './CoderTokenAuth'; + +beforeEach(() => { + jest.useFakeTimers(); +}); + +afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); +}); + +// Aggressively short time to ensure that the class can accept any arbitrary +// timeout value. The auth logic is 90% synchronous, so this has no real risks +const defaultGracePeriodTimeoutMs = 1_000; +const defaultLocalStorageKey = 'backstage-plugin-coder/test'; + +type SetupAuthInputs = Readonly<{ + initialData?: Record; + gracePeriodTimeoutMs?: number; + localStorageKey?: string; +}>; + +type SetupAuthResult = Readonly<{ + localStorage: Storage; + auth: CoderTokenAuth; +}>; + +function setupAuth(inputs?: SetupAuthInputs): SetupAuthResult { + const { + initialData, + localStorageKey = defaultLocalStorageKey, + gracePeriodTimeoutMs = defaultGracePeriodTimeoutMs, + } = inputs ?? {}; + + const localStorage = getMockLocalStorage(initialData); + const auth = new CoderTokenAuth({ + localStorage, + localStorageKey, + gracePeriodTimeoutMs, + }); + + return { auth, localStorage }; +} + +describe(`${CoderTokenAuth.name}`, () => { + describe('Subscriptions', () => { + it('Lets external systems subscribe to auth changes', () => { + const onChange = jest.fn(); + const { auth } = setupAuth(); + auth.subscribe(onChange); + + auth.registerNewToken('blah'); + expect(onChange).toHaveBeenCalled(); + }); + + it('Lets external systems *un*subscribe to auth changes', () => { + const onChange = jest.fn(); + const { auth } = setupAuth(); + auth.subscribe(onChange); + auth.unsubscribe(onChange); + + auth.registerNewToken('blah'); + expect(onChange).not.toHaveBeenCalled(); + }); + }); + + describe('Setting tokens', () => { + it('Will reject empty strings', () => { + const onChange = jest.fn(); + const { auth } = setupAuth(); + auth.subscribe(onChange); + + auth.registerNewToken(''); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("Will reject new token if it's the same as the current one", () => { + const onChange = jest.fn(); + const { auth } = setupAuth(); + auth.subscribe(onChange); + + auth.registerNewToken('blah'); + auth.registerNewToken('blah'); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('Will immediately notify subscriptions that the auth has been invalidated when a new token is set', () => { + const onChange = jest.fn(); + const { auth } = setupAuth(); + auth.subscribe(onChange); + + auth.registerNewToken('blah'); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining>({ + isTokenValid: false, + }), + ); + }); + }); + + describe('getAuthStateSetter', () => { + it('Lets another system set the auth state', () => { + const testToken = 'blah'; + const { auth } = setupAuth(); + + auth.registerNewToken(testToken); + const dispatchNewStatus = auth.getAuthStateSetter(); + dispatchNewStatus(true); + + const snapshot = auth.getStateSnapshot(); + expect(snapshot).toEqual( + expect.objectContaining>({ + token: testToken, + isTokenValid: true, + }), + ); + }); + + it('Rejects state changes if there is no token when the state setter is made', () => { + const onChange = jest.fn(); + const { auth } = setupAuth(); + + auth.subscribe(onChange); + const dispatchNewStatus = auth.getAuthStateSetter(); + dispatchNewStatus(true); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('Disables the state setter if the token changes after the setter was created', () => { + const onChange = jest.fn(); + const { auth } = setupAuth(); + + auth.registerNewToken('dog'); + const dispatchNewStatus = auth.getAuthStateSetter(); + auth.registerNewToken('cat'); + + dispatchNewStatus(true); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("Makes the state setter 'go inert' after a set amount of time (will start rejecting dispatches)", async () => { + const { auth } = setupAuth(); + auth.registerNewToken('blah'); + const dispatchNewStatus = auth.getAuthStateSetter(); + + // Give an extra 100ms to give code time to flush state changes + await jest.advanceTimersByTimeAsync(AUTH_SETTER_TIMEOUT_MS + 100); + dispatchNewStatus(true); + + const snapshot = auth.getStateSnapshot(); + expect(snapshot).toEqual( + expect.objectContaining>({ + isTokenValid: false, + }), + ); + }); + + it("Will eventually leave 'grace period' state when auth validity flips from true to false", async () => { + const { auth } = setupAuth(); + auth.registerNewToken('blah'); + const dispatchNewStatus = auth.getAuthStateSetter(); + + dispatchNewStatus(true); + const snapshot1 = auth.getStateSnapshot(); + expect(snapshot1).toEqual( + expect.objectContaining>({ + isTokenValid: true, + isInsideGracePeriod: true, + }), + ); + + dispatchNewStatus(false); + const snapshot2 = auth.getStateSnapshot(); + expect(snapshot2).toEqual( + expect.objectContaining>({ + isTokenValid: false, + isInsideGracePeriod: true, + }), + ); + + await jest.advanceTimersByTimeAsync(defaultGracePeriodTimeoutMs); + const snapshot3 = auth.getStateSnapshot(); + expect(snapshot3).toEqual( + expect.objectContaining>({ + isTokenValid: false, + isInsideGracePeriod: false, + }), + ); + }); + }); + + describe('localStorage', () => { + it('Will read from localStorage when first initialized', () => { + const testValue = 'blah'; + const { auth } = setupAuth({ + initialData: { + [defaultLocalStorageKey]: testValue, + }, + }); + + const initialStateSnapshot = auth.getStateSnapshot(); + expect(initialStateSnapshot).toEqual( + expect.objectContaining>({ + initialToken: testValue, + token: testValue, + isTokenValid: false, + }), + ); + }); + + it('Will immediately update localStorage when token is cleared', () => { + const { auth, localStorage } = setupAuth({ + initialData: { + [defaultLocalStorageKey]: 'blah', + }, + }); + + auth.clearToken(); + expect(localStorage.getItem(defaultLocalStorageKey)).toEqual(''); + }); + + it('Will write to localStorage when the token is confirmed to be valid', () => { + const testToken = 'blah'; + const { auth, localStorage } = setupAuth(); + + auth.registerNewToken(testToken); + const dispatchNewStatus = auth.getAuthStateSetter(); + dispatchNewStatus(true); + + expect(localStorage.getItem(defaultLocalStorageKey)).toEqual(testToken); + }); + + it('Lets the user define a custom local storage key', () => { + const customKey = 'blah'; + const testToken = 'blah blah'; + + const { auth, localStorage } = setupAuth({ + localStorageKey: customKey, + }); + + auth.registerNewToken(testToken); + const dispatchNewStatus = auth.getAuthStateSetter(); + dispatchNewStatus(true); + + expect(localStorage.getItem(customKey)).toEqual(testToken); + }); + }); +}); diff --git a/plugins/backstage-plugin-coder/src/api/CoderTokenAuth.ts b/plugins/backstage-plugin-coder/src/api/CoderTokenAuth.ts new file mode 100644 index 00000000..b562aece --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/CoderTokenAuth.ts @@ -0,0 +1,207 @@ +import type { AuthValidatorDispatch, CoderAuthApi } from './Auth'; +import { StateSnapshotManager } from '../utils/StateSnapshotManager'; + +export const AUTH_SETTER_TIMEOUT_MS = 20_000; + +type ConfigOptions = Readonly<{ + localStorage: Storage; + localStorageKey: string; + + // Handles auth edge case where a previously-valid token can't be verified. + // Not immediately removing token to provide better UX in case someone's + // internet disconnects for a few seconds + gracePeriodTimeoutMs: number; +}>; + +export const defaultTokenAuthConfigOptions = { + localStorage: window.localStorage, + localStorageKey: 'backstage-plugin-coder/token', + gracePeriodTimeoutMs: 6_000, +} as const satisfies ConfigOptions; + +export type AuthTokenStateSnapshot = Readonly<{ + token: string; + isTokenValid: boolean; + initialToken: string; + isInsideGracePeriod: boolean; +}>; + +type SubscriptionCallback = (snapshot: AuthTokenStateSnapshot) => void; + +export interface CoderTokenAuthApi + extends CoderAuthApi { + readonly initialToken: string; + readonly isInsideGracePeriod: boolean; + + clearToken: () => void; + registerNewToken: (newToken: string) => void; +} + +export class CoderTokenAuth implements CoderTokenAuthApi { + readonly initialToken: string; + private readonly options: ConfigOptions; + private readonly snapshotManager: StateSnapshotManager; + + #token: string; + #isTokenValid: boolean; + #isInsideGracePeriod: boolean; + #distrustGracePeriodTimeoutId: number | undefined; + + constructor(options?: Partial) { + this.options = { ...defaultTokenAuthConfigOptions, ...(options ?? {}) }; + + this.initialToken = this.readTokenFromLocalStorage(); + this.#token = this.initialToken; + this.#isTokenValid = false; + this.#isInsideGracePeriod = true; + this.#distrustGracePeriodTimeoutId = undefined; + + this.snapshotManager = new StateSnapshotManager({ + initialSnapshot: this.prepareNewSnapshot(), + }); + } + + static isInstance(value: unknown): value is CoderTokenAuth { + return value instanceof CoderTokenAuth; + } + + private readTokenFromLocalStorage(): string { + const key = this.options.localStorageKey; + return this.options.localStorage.getItem(key) ?? ''; + } + + private writeTokenToLocalStorage(): boolean { + try { + const key = this.options.localStorageKey; + this.options.localStorage.setItem(key, this.#token); + return true; + } catch { + return false; + } + } + + private prepareNewSnapshot(): AuthTokenStateSnapshot { + return { + token: this.#token, + isTokenValid: this.#isTokenValid, + initialToken: this.initialToken, + isInsideGracePeriod: this.#isInsideGracePeriod, + }; + } + + private notifySubscriptionsOfStateChange(): void { + const newSnapshot = this.prepareNewSnapshot(); + this.snapshotManager.updateSnapshot(newSnapshot); + } + + private setToken(newToken: string): void { + if (newToken === this.#token) { + return; + } + + this.#token = newToken; + this.setIsTokenValid(false); + this.notifySubscriptionsOfStateChange(); + } + + private setIsTokenValid(newIsTokenValidValue: boolean): void { + if (newIsTokenValidValue === this.#isTokenValid) { + return; + } + + if (this.#isTokenValid && !newIsTokenValidValue) { + this.#distrustGracePeriodTimeoutId = window.setTimeout(() => { + this.#isInsideGracePeriod = false; + this.notifySubscriptionsOfStateChange(); + }, this.options.gracePeriodTimeoutMs); + } else { + window.clearTimeout(this.#distrustGracePeriodTimeoutId); + this.#isInsideGracePeriod = true; + } + + this.#isTokenValid = newIsTokenValidValue; + this.notifySubscriptionsOfStateChange(); + + if (this.#isTokenValid) { + this.writeTokenToLocalStorage(); + } + } + + get token(): string { + return this.#token; + } + + get isTokenValid(): boolean { + return this.#isTokenValid; + } + + get isInsideGracePeriod(): boolean { + return this.#isInsideGracePeriod; + } + + /* *************************************************************************** + * All public functions should be defined as arrow functions to ensure they + * can be passed around React without risk of losing their "this" context + ****************************************************************************/ + + subscribe = (callback: SubscriptionCallback): (() => void) => { + return this.snapshotManager.subscribe(callback); + }; + + unsubscribe = (callback: SubscriptionCallback): void => { + return this.snapshotManager.unsubscribe(callback); + }; + + getStateSnapshot = (): AuthTokenStateSnapshot => { + return this.snapshotManager.getSnapshot(); + }; + + registerNewToken = (newToken: string): void => { + if (newToken !== '') { + this.setToken(newToken); + } + }; + + clearToken = (): void => { + this.setToken(''); + this.writeTokenToLocalStorage(); + }; + + getAuthStateSetter = (): AuthValidatorDispatch => { + const tokenOnSetup = this.#token; + if (tokenOnSetup === '') { + return () => { + // Do nothing - setter is fully inert because there's no token loaded to + // validate, and token changes would disable the function anyway + }; + } + + let allowUpdate = true; + let disableUpdatesTimeoutId: number | undefined = undefined; + + const onTokenChange = (newSnapshot: AuthTokenStateSnapshot) => { + if (!allowUpdate || newSnapshot.token === tokenOnSetup) { + return; + } + + allowUpdate = false; + this.snapshotManager.unsubscribe(onTokenChange); + window.clearTimeout(disableUpdatesTimeoutId); + }; + + this.snapshotManager.subscribe(onTokenChange); + + // Have to make sure that we eventually unsubscribe so that the onChange + // callback can be garbage-collected, and we don't have a memory leak + disableUpdatesTimeoutId = window.setTimeout(() => { + allowUpdate = false; + this.snapshotManager.unsubscribe(onTokenChange); + }, AUTH_SETTER_TIMEOUT_MS); + + return newStatus => { + if (allowUpdate) { + this.setIsTokenValid(newStatus); + } + }; + }; +} diff --git a/plugins/backstage-plugin-coder/src/api/errors.ts b/plugins/backstage-plugin-coder/src/api/errors.ts new file mode 100644 index 00000000..854d7283 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/errors.ts @@ -0,0 +1,32 @@ +import type { AxiosHeaderValue, AxiosResponse } from 'axios'; + +/** + * Makes it easier to expose HTTP responses in the event of errors and also + * gives TypeScript a faster way to type-narrow on those errors + */ +export class BackstageHttpError extends Error { + #failedResponse: AxiosResponse; + + constructor(errorMessage: string, failedResponse: AxiosResponse) { + super(errorMessage); + this.name = 'BackstageHttpError'; + this.#failedResponse = failedResponse; + } + + static isInstance(value: unknown): value is BackstageHttpError { + return value instanceof BackstageHttpError; + } + + get status(): number { + return this.#failedResponse.status; + } + + get ok(): boolean { + const status = this.#failedResponse.status; + return !(status >= 200 && status <= 299); + } + + get contentType(): AxiosHeaderValue | undefined { + return this.#failedResponse.headers['Content-Type']; + } +} diff --git a/plugins/backstage-plugin-coder/src/api/queryOptions.ts b/plugins/backstage-plugin-coder/src/api/queryOptions.ts new file mode 100644 index 00000000..5837a2cf --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/queryOptions.ts @@ -0,0 +1,84 @@ +import { type UseQueryOptions } from '@tanstack/react-query'; +import type { Workspace } from '../typesConstants'; +import { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; +import type { ReactCoderClient } from '../hooks/useCoderClient'; + +export const CODER_QUERY_KEY_PREFIX = 'coder-backstage-plugin'; +const PENDING_REFETCH_INTERVAL_MS = 5_000; +const BACKGROUND_REFETCH_INTERVAL_MS = 60_000; + +function getCoderWorkspacesRefetchInterval( + workspaces?: readonly Workspace[], +): number | false { + if (workspaces === undefined) { + // Boolean false indicates that no periodic refetching should happen (but + // a refetch can still happen in the background in response to user action) + return false; + } + + const areAnyWorkspacesPending = workspaces.some(ws => { + if (ws.latest_build.status === 'pending') { + return true; + } + + return ws.latest_build.resources.some(resource => { + const agents = resource.agents; + return agents?.some(agent => agent.status === 'connecting') ?? false; + }); + }); + + return areAnyWorkspacesPending + ? PENDING_REFETCH_INTERVAL_MS + : BACKGROUND_REFETCH_INTERVAL_MS; +} + +function getSharedWorkspacesQueryKey(coderQuery: string) { + return [CODER_QUERY_KEY_PREFIX, 'workspaces', coderQuery] as const; +} + +type WorkspacesInputs = Readonly<{ + client: ReactCoderClient; + coderQuery: string; +}>; + +export function workspaces({ + client, + coderQuery, +}: WorkspacesInputs): UseQueryOptions { + return { + enabled: client.state.isAuthValid, + queryKey: getSharedWorkspacesQueryKey(coderQuery), + keepPreviousData: coderQuery !== '', + refetchInterval: getCoderWorkspacesRefetchInterval, + queryFn: async () => { + const response = await client.api.getWorkspaces({ + q: coderQuery, + limit: 0, + }); + + return response.workspaces; + }, + }; +} + +type WorkspacesByRepoInputs = Readonly<{ + client: ReactCoderClient; + coderQuery: string; + workspacesConfig: CoderWorkspacesConfig; +}>; + +export function workspacesByRepo({ + client, + coderQuery, + workspacesConfig, +}: WorkspacesByRepoInputs) { + const enabled = client.state.isAuthValid && coderQuery !== ''; + + return { + queryKey: [...getSharedWorkspacesQueryKey(coderQuery), workspacesConfig], + queryFn: () => client.api.getWorkspacesByRepo(coderQuery, workspacesConfig), + enabled, + keepPreviousData: enabled, + refetchInterval: getCoderWorkspacesRefetchInterval, + }; +} diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthDistrustedForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthDistrustedForm.tsx index 1a63a24a..7a8d7113 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthDistrustedForm.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthDistrustedForm.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { CoderLogo } from '../CoderLogo'; import { LinkButton } from '@backstage/core-components'; import { makeStyles } from '@material-ui/core'; -import { useCoderAuth } from '../CoderProvider'; +import { useCoderTokenAuth } from '../../hooks/useCoderTokenAuth'; const useStyles = makeStyles(theme => ({ root: { @@ -31,7 +31,7 @@ const useStyles = makeStyles(theme => ({ export const CoderAuthDistrustedForm = () => { const styles = useStyles(); - const { ejectToken } = useCoderAuth(); + const { ejectToken } = useCoderTokenAuth(); return (
diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx index f7e926b2..0e92ef1a 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx @@ -1,10 +1,10 @@ import React, { type FormEvent, useState } from 'react'; import { useId } from '../../hooks/hookPolyfills'; +import { useCoderAppConfig } from '../CoderProvider'; import { - type CoderAuthStatus, - useCoderAppConfig, - useCoderAuth, -} from '../CoderProvider'; + type CoderTokenAuthUiStatus, + useCoderTokenAuth, +} from '../../hooks/useCoderTokenAuth'; import { CoderLogo } from '../CoderLogo'; import { Link, LinkButton } from '@backstage/core-components'; @@ -49,7 +49,7 @@ export const CoderAuthInputForm = () => { const hookId = useId(); const styles = useStyles(); const appConfig = useCoderAppConfig(); - const { status, registerNewToken } = useCoderAuth(); + const { status, registerNewToken } = useCoderTokenAuth(); const onSubmit = (event: FormEvent) => { event.preventDefault(); @@ -197,7 +197,7 @@ const useInvalidStatusStyles = makeStyles(theme => ({ })); type InvalidStatusProps = Readonly<{ - authStatus: CoderAuthStatus; + authStatus: CoderTokenAuthUiStatus; bannerId: string; }>; diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx index 43199c04..12c73300 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx @@ -2,7 +2,10 @@ import React from 'react'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { CoderProviderWithMockAuth } from '../../testHelpers/setup'; -import type { CoderAuth, CoderAuthStatus } from '../CoderProvider'; +import type { + CoderTokenAuthUiStatus, + CoderTokenUiAuth, +} from '../../hooks/useCoderTokenAuth'; import { mockAppConfig, mockAuthStates, @@ -12,7 +15,7 @@ import { CoderAuthWrapper } from './CoderAuthWrapper'; import { renderInTestApp } from '@backstage/test-utils'; type RenderInputs = Readonly<{ - authStatus: CoderAuthStatus; + authStatus: CoderTokenAuthUiStatus; childButtonText?: string; }>; @@ -23,7 +26,7 @@ async function renderAuthWrapper({ const ejectToken = jest.fn(); const registerNewToken = jest.fn(); - const auth: CoderAuth = { + const auth: CoderTokenUiAuth = { ...mockAuthStates[authStatus], ejectToken, registerNewToken, @@ -85,7 +88,7 @@ describe(`${CoderAuthWrapper.name}`, () => { it("Is displayed when the user's auth status cannot be verified", async () => { const buttonText = 'Not sure if you should be able to see me'; const distrustedTextMatcher = /Unable to verify token authenticity/; - const distrustedStatuses: readonly CoderAuthStatus[] = [ + const distrustedStatuses: readonly CoderTokenAuthUiStatus[] = [ 'distrusted', 'noInternetConnection', 'deploymentUnavailable', @@ -149,7 +152,7 @@ describe(`${CoderAuthWrapper.name}`, () => { it("Is displayed when the token either doesn't exist or is definitely not valid", async () => { const buttonText = "You're not allowed to gaze upon my visage"; const tokenFormMatcher = /Please enter a new token/; - const statusesForInvalidUser: readonly CoderAuthStatus[] = [ + const statusesForInvalidUser: readonly CoderTokenAuthUiStatus[] = [ 'invalid', 'tokenMissing', ]; @@ -192,7 +195,7 @@ describe(`${CoderAuthWrapper.name}`, () => { }); it('Lets the user dismiss any notifications for invalid/authenticating states', async () => { - const authStatuses: readonly CoderAuthStatus[] = [ + const authStatuses: readonly CoderTokenAuthUiStatus[] = [ 'invalid', 'authenticating', ]; diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.tsx index b0e6ee22..1e6a7957 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.tsx @@ -1,5 +1,5 @@ import React, { type FC, type PropsWithChildren } from 'react'; -import { useCoderAuth } from '../CoderProvider'; +import { useCoderTokenAuth } from '../../hooks/useCoderTokenAuth'; import { InfoCard } from '@backstage/core-components'; import { CoderAuthDistrustedForm } from './CoderAuthDistrustedForm'; import { makeStyles } from '@material-ui/core'; @@ -29,7 +29,7 @@ type WrapperProps = Readonly< >; export const CoderAuthWrapper = ({ children, type }: WrapperProps) => { - const auth = useCoderAuth(); + const auth = useCoderTokenAuth(); if (auth.isAuthenticated) { return <>{children}; } diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 8dd9a741..7403f30a 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -1,151 +1,17 @@ -import React, { - type PropsWithChildren, - createContext, - useContext, - useEffect, - useState, -} from 'react'; - -import { - type UseQueryResult, - useQuery, - useQueryClient, -} from '@tanstack/react-query'; - +import React, { type PropsWithChildren, createContext, useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { BackstageHttpError } from '../../api/errors'; import { - BackstageHttpError, - CODER_QUERY_KEY_PREFIX, - authQueryKey, - authValidation, -} from '../../api'; -import { useBackstageEndpoints } from '../../hooks/useBackstageEndpoints'; -import { identityApiRef, useApi } from '@backstage/core-plugin-api'; - -const TOKEN_STORAGE_KEY = 'coder-backstage-plugin/token'; - -// Handles auth edge case where a previously-valid token can't be verified. Not -// immediately removing token to provide better UX in case someone's internet -// disconnects for a few seconds -const AUTH_GRACE_PERIOD_TIMEOUT_MS = 6_000; - -type AuthState = Readonly< - | { - status: 'initializing' | 'tokenMissing'; - token: undefined; - error: undefined; - } - | { - status: 'authenticated' | 'distrustedWithGracePeriod'; - token: string; - error: undefined; - } - | { - // Distrusted represents a token that could be valid, but we are unable to - // verify it within an allowed window. invalid is definitely, 100% invalid - status: - | 'authenticating' - | 'invalid' - | 'distrusted' - | 'noInternetConnection' - | 'deploymentUnavailable'; - token: undefined; - error: unknown; - } ->; - -export type CoderAuthStatus = AuthState['status']; -export type CoderAuth = Readonly< - AuthState & { - isAuthenticated: boolean; - tokenLoadedOnMount: boolean; - registerNewToken: (newToken: string) => void; - ejectToken: () => void; - } ->; - -function isAuthValid(state: AuthState): boolean { - return ( - state.status === 'authenticated' || - state.status === 'distrustedWithGracePeriod' - ); -} - -type ValidCoderAuth = Extract< - CoderAuth, - { status: 'authenticated' | 'distrustedWithGracePeriod' } ->; - -export function assertValidCoderAuth( - auth: CoderAuth, -): asserts auth is ValidCoderAuth { - if (!isAuthValid(auth)) { - throw new Error('Coder auth is not valid'); - } -} - -export const AuthContext = createContext(null); - -export function useCoderAuth(): CoderAuth { - const contextValue = useContext(AuthContext); - if (contextValue === null) { - throw new Error( - `Hook ${useCoderAuth.name} is being called outside of CoderProvider`, - ); - } - - return contextValue; -} + type CoderTokenUiAuth, + tokenAuthQueryKey, + useCoderTokenAuth, +} from '../../hooks/useCoderTokenAuth'; +export const AuthContext = createContext(null); type CoderAuthProviderProps = Readonly>; export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { - const identity = useApi(identityApiRef); - const { baseUrl } = useBackstageEndpoints(); - const [isInsideGracePeriod, setIsInsideGracePeriod] = useState(true); - - // Need to split hairs, because the query object can be disabled. Only want to - // expose the initializing state if the app mounts with a token already in - // localStorage - const [authToken, setAuthToken] = useState(readAuthToken); - const [readonlyInitialAuthToken] = useState(authToken); - - const authValidityQuery = useQuery({ - ...authValidation({ baseUrl, authToken, identity }), - refetchOnWindowFocus: query => query.state.data !== false, - }); - - const authState = generateAuthState({ - authToken, - authValidityQuery, - isInsideGracePeriod, - initialAuthToken: readonlyInitialAuthToken, - }); - - // Mid-render state sync to avoid unnecessary re-renders that useEffect would - // introduce, especially since we don't know how costly re-renders could be in - // someone's arbitrarily-large Backstage deployment - if (!isInsideGracePeriod && authState.status === 'authenticated') { - setIsInsideGracePeriod(true); - } - - useEffect(() => { - if (authState.status === 'authenticated') { - window.localStorage.setItem(TOKEN_STORAGE_KEY, authState.token); - } - }, [authState.status, authState.token]); - - // Starts ticking down seconds before we start fully distrusting a token - useEffect(() => { - if (authState.status !== 'distrustedWithGracePeriod') { - return undefined; - } - - const distrustTimeoutId = window.setTimeout(() => { - setIsInsideGracePeriod(false); - }, AUTH_GRACE_PERIOD_TIMEOUT_MS); - - return () => window.clearTimeout(distrustTimeoutId); - }, [authState.status]); + const coderAuth = useCoderTokenAuth(); // Sets up subscription to spy on potentially-expired tokens. Can't do this // outside React because we let the user connect their own queryClient @@ -158,7 +24,7 @@ export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { const queryError = event.query.state.error; const shouldRevalidate = !isRefetchingTokenQuery && - queryError instanceof BackstageHttpError && + BackstageHttpError.isInstance(queryError) && queryError.status === 401; if (!shouldRevalidate) { @@ -166,7 +32,7 @@ export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { } isRefetchingTokenQuery = true; - await queryClient.refetchQueries({ queryKey: authQueryKey }); + await queryClient.refetchQueries({ queryKey: tokenAuthQueryKey }); isRefetchingTokenQuery = false; }); @@ -174,162 +40,6 @@ export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { }, [queryClient]); return ( - { - if (newToken !== '') { - setAuthToken(newToken); - } - }, - ejectToken: () => { - window.localStorage.removeItem(TOKEN_STORAGE_KEY); - queryClient.removeQueries({ queryKey: [CODER_QUERY_KEY_PREFIX] }); - setAuthToken(''); - }, - }} - > - {children} - + {children} ); }; - -type GenerateAuthStateInputs = Readonly<{ - authToken: string; - initialAuthToken: string; - authValidityQuery: UseQueryResult; - isInsideGracePeriod: boolean; -}>; - -/** - * This function isn't too big, but it is accounting for a lot of possible - * configurations that authValidityQuery can be in while background fetches and - * re-fetches are happening. Can't get away with checking the .status alone - * - * @see {@link https://tkdodo.eu/blog/status-checks-in-react-query} - */ -function generateAuthState({ - authToken, - initialAuthToken, - authValidityQuery, - isInsideGracePeriod, -}: GenerateAuthStateInputs): AuthState { - const isInitializing = - initialAuthToken !== '' && - authValidityQuery.isLoading && - authValidityQuery.isFetching && - !authValidityQuery.isFetchedAfterMount; - - if (isInitializing) { - return { - status: 'initializing', - token: undefined, - error: undefined, - }; - } - - // Checking the token here is more direct than trying to check the query - // object's state transitions; React Query has no simple isEnabled property - if (!authToken) { - return { - status: 'tokenMissing', - token: undefined, - error: undefined, - }; - } - - if (authValidityQuery.error instanceof BackstageHttpError) { - const deploymentLikelyUnavailable = - authValidityQuery.error.status === 504 || - (authValidityQuery.error.status === 200 && - authValidityQuery.error.contentType !== 'application/json'); - - if (deploymentLikelyUnavailable) { - return { - status: 'deploymentUnavailable', - token: undefined, - error: authValidityQuery.error, - }; - } - } - - const isTokenValidFromPrevFetch = authValidityQuery.data === true; - if (isTokenValidFromPrevFetch) { - const canTrustAuthThisRender = - authValidityQuery.isSuccess && !authValidityQuery.isPaused; - if (canTrustAuthThisRender) { - return { - status: 'authenticated', - token: authToken, - error: undefined, - }; - } - - if (isInsideGracePeriod) { - return { - status: 'distrustedWithGracePeriod', - token: authToken, - error: undefined, - }; - } - - return { - status: 'distrusted', - token: undefined, - error: authValidityQuery.error, - }; - } - - // Have to include isLoading here because the auth query uses the - // isPreviousData property to mask the fact that we're shifting to different - // query keys and cache pockets each time the token value changes - const isAuthenticating = - authValidityQuery.isLoading || - (authValidityQuery.isRefetching && - ((authValidityQuery.isError && authValidityQuery.data !== true) || - (authValidityQuery.isSuccess && authValidityQuery.data === false))); - - if (isAuthenticating) { - return { - status: 'authenticating', - token: undefined, - error: authValidityQuery.error, - }; - } - - // Catches edge case where only the Backstage client is up, so the token can't - // be verified (even if it's perfectly valid); all Coder proxy requests are - // set up to time out after 20 seconds - const isCoderDeploymentDown = - authValidityQuery.error instanceof Error && - authValidityQuery.error.name === 'TimeoutError'; - if (isCoderDeploymentDown) { - return { - status: 'distrusted', - token: undefined, - error: authValidityQuery.error, - }; - } - - // Start of catch-all logic; handles remaining possible cases, and aliases - // "impossible" cases to possible ones (mainly to make compiler happy) - if (authValidityQuery.isPaused) { - return { - status: 'noInternetConnection', - token: undefined, - error: authValidityQuery.error, - }; - } - - return { - status: 'invalid', - token: undefined, - error: authValidityQuery.error, - }; -} - -function readAuthToken(): string { - return window.localStorage.getItem(TOKEN_STORAGE_KEY) ?? ''; -} diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx index 41e75bee..514c9b61 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx @@ -1,29 +1,37 @@ -import React, { PropsWithChildren } from 'react'; +import React from 'react'; import { renderHook } from '@testing-library/react'; import { act, waitFor } from '@testing-library/react'; -import { TestApiProvider, wrapInTestApp } from '@backstage/test-utils'; +import { TestApiProvider } from '@backstage/test-utils'; import { configApiRef, + discoveryApiRef, errorApiRef, identityApiRef, } from '@backstage/core-plugin-api'; import { CoderProvider } from './CoderProvider'; import { useCoderAppConfig } from './CoderAppConfigProvider'; -import { type CoderAuth, useCoderAuth } from './CoderAuthProvider'; +import { + type CoderTokenUiAuth, + useCoderTokenAuth, +} from '../../hooks/useCoderTokenAuth'; import { getMockConfigApi, + getMockDiscoveryApi, getMockErrorApi, getMockIdentityApi, mockAppConfig, mockCoderAuthToken, + setupCoderClient, } from '../../testHelpers/mockBackstageData'; import { getMockQueryClient, renderHookAsCoderEntity, } from '../../testHelpers/setup'; +import { coderAuthApiRef } from '../../api/Auth'; +import { coderClientApiRef } from '../../api/CoderClient'; describe(`${CoderProvider.name}`, () => { describe('AppConfig', () => { @@ -47,53 +55,31 @@ describe(`${CoderProvider.name}`, () => { expect(result.current).toBe(mockAppConfig); } }); - - // Our documentation pushes people to define the config outside a component, - // just to stabilize the memory reference for the value, and make sure that - // memoization caches don't get invalidated too often. This test is just a - // safety net to catch what happens if someone forgets - test('Context value will change by reference on re-render if defined inline inside a parent', () => { - const ParentComponent = ({ children }: PropsWithChildren) => { - const configThatChangesEachRender = { ...mockAppConfig }; - - return wrapInTestApp( - - - {children} - - , - ); - }; - - const { result, rerender } = renderHook(useCoderAppConfig, { - wrapper: ParentComponent, - }); - - const firstResult = result.current; - rerender(); - - expect(result.current).not.toBe(firstResult); - expect(result.current).toEqual(firstResult); - }); }); describe('Auth', () => { // Can't use the render helpers because they all assume that the auth isn't - // core to the functionality. In this case, you do need to bring in the full - // CoderProvider - const renderUseCoderAuth = () => { - return renderHook(useCoderAuth, { + // core to the functionality and can be hand-waved. In this case, you do + // need to bring in the full CoderProvider to verify it's working + const renderUseCoderAuth = async () => { + const identityApi = getMockIdentityApi(); + const discoveryApi = getMockDiscoveryApi(); + + const { authApi, coderClientApi } = setupCoderClient({ + discoveryApi, + identityApi, + }); + + const renderResult = renderHook(useCoderTokenAuth, { wrapper: ({ children }) => ( { ), }); + + await waitFor(() => expect(renderResult.result.current).not.toBe(null)); + return renderResult; }; it('Should let the user eject their auth token', async () => { - const { result } = renderUseCoderAuth(); + const { result } = await renderUseCoderAuth(); act(() => result.current.registerNewToken(mockCoderAuthToken)); await waitFor(() => { expect(result.current).toEqual( - expect.objectContaining>({ + expect.objectContaining>({ status: 'authenticated', token: mockCoderAuthToken, error: undefined, @@ -124,7 +113,7 @@ describe(`${CoderProvider.name}`, () => { act(() => result.current.ejectToken()); expect(result.current).toEqual( - expect.objectContaining>({ + expect.objectContaining>({ status: 'tokenMissing', token: undefined, }), diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx index 4c8d0898..1b825404 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { CoderAuthProvider } from './CoderAuthProvider'; import { CoderAppConfigProvider } from './CoderAppConfigProvider'; import { CoderErrorBoundary } from '../CoderErrorBoundary'; -import { BackstageHttpError } from '../../api'; +import { BackstageHttpError } from '../../api/errors'; const MAX_FETCH_FAILURES = 3; @@ -15,7 +15,7 @@ export type CoderProviderProps = ComponentProps & const shouldRetryRequest = (failureCount: number, error: unknown): boolean => { const isBelowThreshold = failureCount < MAX_FETCH_FAILURES; - if (!(error instanceof BackstageHttpError)) { + if (!BackstageHttpError.isInstance(error)) { return isBelowThreshold; } diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.test.tsx index 008d931a..28e7250e 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.test.tsx @@ -6,7 +6,7 @@ import { mockAuthStates, mockCoderWorkspacesConfig, } from '../../testHelpers/mockBackstageData'; -import type { CoderAuth } from '../CoderProvider'; +import type { CoderTokenUiAuth } from '../../hooks/useCoderTokenAuth'; import { CardContext, WorkspacesCardContext } from './Root'; import { ExtraActionsButton } from './ExtraActionsButton'; @@ -30,7 +30,10 @@ type RenderInputs = Readonly<{ async function renderButton({ buttonText }: RenderInputs) { const ejectToken = jest.fn(); - const auth: CoderAuth = { ...mockAuthStates.authenticated, ejectToken }; + const auth: CoderTokenUiAuth = { + ...mockAuthStates.authenticated, + ejectToken, + }; /** * Pretty sure there has to be a more elegant and fault-tolerant way of diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.tsx index 57a41922..1df6e2b9 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.tsx @@ -7,7 +7,7 @@ import React, { } from 'react'; import { useId } from '../../hooks/hookPolyfills'; -import { useCoderAuth } from '../CoderProvider'; +import { useCoderTokenAuth } from '../../hooks/useCoderTokenAuth'; import { useWorkspacesCardContext } from './Root'; import { VisuallyHidden } from '../VisuallyHidden'; @@ -102,7 +102,7 @@ export const ExtraActionsButton = ({ const hookId = useId(); const [loadedAnchor, setLoadedAnchor] = useState(); const refreshWorkspaces = useRefreshWorkspaces(); - const { ejectToken } = useCoderAuth(); + const { ejectToken } = useCoderTokenAuth(); const styles = useStyles(); const closeMenu = () => setLoadedAnchor(undefined); diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.tsx index 23623a72..50a5240d 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.tsx @@ -1,6 +1,6 @@ import React, { ForwardedRef, HTMLAttributes, useState } from 'react'; -import { useBackstageEndpoints } from '../../hooks/useBackstageEndpoints'; import { Theme, makeStyles } from '@material-ui/core'; +import { useCoderClient } from '../../hooks/useCoderClient'; type WorkspaceListIconProps = Readonly< Omit, 'children' | 'aria-hidden'> & { @@ -56,10 +56,11 @@ export const WorkspacesListIcon = ({ ...delegatedProps }: WorkspaceListIconProps) => { const [hasError, setHasError] = useState(false); - const { assetsProxyUrl } = useBackstageEndpoints(); + const coderClient = useCoderClient(); + const { assetsRoute } = coderClient.state; const styles = useStyles({ - isEmoji: src.startsWith(`${assetsProxyUrl}/emoji`), + isEmoji: src.startsWith(`${assetsRoute}/emoji`), }); return ( diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx index 86904329..3c8a1537 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx @@ -9,11 +9,13 @@ import { type Theme, makeStyles } from '@material-ui/core'; import { useId } from '../../hooks/hookPolyfills'; import { useCoderAppConfig } from '../CoderProvider'; -import { getWorkspaceAgentStatuses } from '../../api'; - -import type { Workspace, WorkspaceStatus } from '../../typesConstants'; import { WorkspacesListIcon } from './WorkspacesListIcon'; import { VisuallyHidden } from '../VisuallyHidden'; +import { + Workspace, + WorkspaceAgentStatus, + WorkspaceStatus, +} from '../../typesConstants'; type StyleKey = | 'root' @@ -239,6 +241,7 @@ export const WorkspacesListItem = ({ }; const deletingStatuses: readonly WorkspaceStatus[] = ['deleting', 'deleted']; + const offlineStatuses: readonly WorkspaceStatus[] = [ 'stopped', 'stopping', @@ -298,6 +301,27 @@ function stopClickEventBubbling(event: MouseEvent | KeyboardEvent): void { } } +function getWorkspaceAgentStatuses( + workspace: Workspace, +): readonly WorkspaceAgentStatus[] { + const uniqueStatuses: WorkspaceAgentStatus[] = []; + + for (const resource of workspace.latest_build.resources) { + if (resource.agents === undefined) { + continue; + } + + for (const agent of resource.agents) { + const status = agent.status; + if (!uniqueStatuses.includes(status)) { + uniqueStatuses.push(status); + } + } + } + + return uniqueStatuses; +} + function toUppercase(s: string): string { return s.slice(0, 1).toUpperCase() + s.slice(1).toLowerCase(); } diff --git a/plugins/backstage-plugin-coder/src/hooks/useBackstageEndpoints.test.ts b/plugins/backstage-plugin-coder/src/hooks/useBackstageEndpoints.test.ts deleted file mode 100644 index d245e5d3..00000000 --- a/plugins/backstage-plugin-coder/src/hooks/useBackstageEndpoints.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { renderHookAsCoderEntity } from '../testHelpers/setup'; - -import { - UseBackstageEndpointResult, - useBackstageEndpoints, -} from './useBackstageEndpoints'; - -import { - mockBackstageAssetsEndpoint, - mockBackstageProxyEndpoint, - mockBackstageUrlRoot, -} from '../testHelpers/mockBackstageData'; - -describe(`${useBackstageEndpoints.name}`, () => { - it('Should provide pre-formatted URLs for interacting with Backstage endpoints', async () => { - const { result } = await renderHookAsCoderEntity(useBackstageEndpoints); - - expect(result.current).toEqual( - expect.objectContaining({ - baseUrl: mockBackstageUrlRoot, - assetsProxyUrl: mockBackstageAssetsEndpoint, - apiProxyUrl: mockBackstageProxyEndpoint, - }), - ); - }); -}); diff --git a/plugins/backstage-plugin-coder/src/hooks/useBackstageEndpoints.ts b/plugins/backstage-plugin-coder/src/hooks/useBackstageEndpoints.ts deleted file mode 100644 index 7defa50f..00000000 --- a/plugins/backstage-plugin-coder/src/hooks/useBackstageEndpoints.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { configApiRef, useApi } from '@backstage/core-plugin-api'; -import { ASSETS_ROUTE_PREFIX, API_ROUTE_PREFIX } from '../api'; - -export type UseBackstageEndpointResult = Readonly<{ - baseUrl: string; - assetsProxyUrl: string; - apiProxyUrl: string; -}>; - -export function useBackstageEndpoints(): UseBackstageEndpointResult { - const backstageConfig = useApi(configApiRef); - const baseUrl = backstageConfig.getString('backend.baseUrl'); - - return { - baseUrl, - assetsProxyUrl: `${baseUrl}${ASSETS_ROUTE_PREFIX}`, - apiProxyUrl: `${baseUrl}${API_ROUTE_PREFIX}`, - }; -} diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderClient.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderClient.ts new file mode 100644 index 00000000..f05246ee --- /dev/null +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderClient.ts @@ -0,0 +1,40 @@ +import { useSyncExternalStore } from 'use-sync-external-store/shim'; +import { useApi } from '@backstage/core-plugin-api'; +import { + type BackstageCoderSdkApi, + type CoderClientSnapshot, + coderClientApiRef, + CoderClient, +} from '../api/CoderClient'; + +type ClientHookInternals = Readonly<{ + validateAuth: CoderClient['validateAuth']; +}>; + +export type ReactCoderClient = Readonly<{ + api: BackstageCoderSdkApi; + state: CoderClientSnapshot; + + /** + * @private A collection of properties and methods that are used as + * implementation details for the Coder plugin. + * + * These will never be documented - assume that any and all properties in here + * can be changed/added/removed, even between patch releases. + */ + internals: ClientHookInternals; +}>; + +export function useCoderClient(): ReactCoderClient { + const clientApi = useApi(coderClientApiRef); + const safeApiStateSnapshot = useSyncExternalStore( + clientApi.subscribe, + clientApi.getStateSnapshot, + ); + + return { + api: clientApi.sdkApi, + state: safeApiStateSnapshot, + internals: { validateAuth: clientApi.validateAuth }, + }; +} diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderTokenAuth.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderTokenAuth.ts new file mode 100644 index 00000000..a6d613d7 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderTokenAuth.ts @@ -0,0 +1,218 @@ +import { useSyncExternalStore } from 'use-sync-external-store/shim'; +import { type UseQueryResult, useQuery } from '@tanstack/react-query'; +import { useApi } from '@backstage/core-plugin-api'; +import { CODER_QUERY_KEY_PREFIX } from '../api/queryOptions'; +import { BackstageHttpError } from '../api/errors'; +import { + type AuthTokenStateSnapshot, + CoderTokenAuth, +} from '../api/CoderTokenAuth'; +import { coderAuthApiRef } from '../api/Auth'; +import { useCoderClient } from './useCoderClient'; + +export const tokenAuthQueryKey = [ + CODER_QUERY_KEY_PREFIX, + 'auth-token', +] as const; + +type TokenAuthStatusInfo = Readonly< + | { + status: 'initializing' | 'tokenMissing'; + token: undefined; + error: undefined; + } + | { + status: 'authenticated' | 'distrustedWithGracePeriod'; + token: string; + error: undefined; + } + | { + // Distrusted represents a token that could be valid, but we are unable to + // verify it within an allowed window. invalid is definitely, 100% invalid + status: + | 'authenticating' + | 'invalid' + | 'distrusted' + | 'noInternetConnection' + | 'deploymentUnavailable'; + token: undefined; + error: unknown; + } +>; + +export type CoderTokenAuthUiStatus = TokenAuthStatusInfo['status']; + +export type CoderTokenUiAuth = Readonly< + TokenAuthStatusInfo & { + isAuthenticated: boolean; + tokenLoadedOnMount: boolean; + registerNewToken: (newToken: string) => void; + ejectToken: () => void; + } +>; + +const validCoderStatuses: readonly CoderTokenAuthUiStatus[] = [ + 'authenticated', + 'distrustedWithGracePeriod', +]; + +export function useCoderTokenAuth(): CoderTokenUiAuth { + const authApi = useApi(coderAuthApiRef); + if (!CoderTokenAuth.isInstance(authApi)) { + throw new Error('coderAuthRef is not configured for token auth'); + } + + // Binds React to the auth API in a render-safe way – use snapshot values as + // much as possible; don't access non-functions directly from the API + const safeApiSnapshot = useSyncExternalStore( + authApi.subscribe, + authApi.getStateSnapshot, + ); + + const coderClient = useCoderClient(); + const isQueryEnabled = Boolean(safeApiSnapshot.token); + + const authValidityQuery = useQuery({ + queryKey: [...tokenAuthQueryKey, safeApiSnapshot.token], + queryFn: coderClient.internals.validateAuth, + enabled: isQueryEnabled, + keepPreviousData: isQueryEnabled, + refetchOnWindowFocus: query => query.state.data !== false, + }); + + const info = deriveStatusInfo(safeApiSnapshot, authValidityQuery); + return { + ...info, + tokenLoadedOnMount: safeApiSnapshot.initialToken !== '', + isAuthenticated: validCoderStatuses.includes(info.status), + registerNewToken: authApi.registerNewToken, + ejectToken: authApi.clearToken, + }; +} + +/** + * This function is big and clunky, but at least it's 100% pure and testable. + * It has to account for a lot of possible configurations that authValidityQuery + * can be in while background fetches and re-fetches are happening. Can't get + * away with checking its .status property alone + * + * @see {@link https://tkdodo.eu/blog/status-checks-in-react-query} + */ +export function deriveStatusInfo( + authStateSnapshot: AuthTokenStateSnapshot, + authValidityQuery: UseQueryResult, +): TokenAuthStatusInfo { + const { token, initialToken, isInsideGracePeriod } = authStateSnapshot; + const isInitializing = + initialToken !== '' && + authValidityQuery.isLoading && + authValidityQuery.isFetching && + !authValidityQuery.isFetchedAfterMount; + + if (isInitializing) { + return { + status: 'initializing', + token: undefined, + error: undefined, + }; + } + + // Checking the token here is more direct than trying to check the query + // object's state transitions; React Query has no simple isEnabled property + if (!token) { + return { + status: 'tokenMissing', + token: undefined, + error: undefined, + }; + } + + 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: undefined, + error: authValidityQuery.error, + }; + } + } + + const isTokenValidFromPrevFetch = authValidityQuery.data === true; + if (isTokenValidFromPrevFetch) { + const canTrustAuthThisRender = + authValidityQuery.isSuccess && !authValidityQuery.isPaused; + if (canTrustAuthThisRender) { + return { + token: token, + status: 'authenticated', + error: undefined, + }; + } + + if (isInsideGracePeriod) { + return { + token: token, + status: 'distrustedWithGracePeriod', + error: undefined, + }; + } + + return { + status: 'distrusted', + token: undefined, + error: authValidityQuery.error, + }; + } + + // Have to include isLoading here because the auth query uses the + // isPreviousData property to mask the fact that we're shifting to different + // query keys and cache pockets each time the token value changes + const isAuthenticating = + authValidityQuery.isLoading || + (authValidityQuery.isRefetching && + ((authValidityQuery.isError && authValidityQuery.data !== true) || + (authValidityQuery.isSuccess && authValidityQuery.data === false))); + + if (isAuthenticating) { + return { + status: 'authenticating', + token: undefined, + error: authValidityQuery.error, + }; + } + + // Catches edge case where only the Backstage client is up, so the token can't + // be verified (even if it's perfectly valid); all Coder proxy requests are + // set up to time out after 20 seconds + const isCoderDeploymentDown = + authValidityQuery.error instanceof Error && + authValidityQuery.error.name === 'TimeoutError'; + if (isCoderDeploymentDown) { + return { + status: 'distrusted', + token: undefined, + error: authValidityQuery.error, + }; + } + + // Start of catch-all logic; handles remaining possible cases, and aliases + // "impossible" cases to possible ones (mainly to make compiler happy) + if (authValidityQuery.isPaused) { + return { + status: 'noInternetConnection', + token: undefined, + error: authValidityQuery.error, + }; + } + + return { + status: 'invalid', + token: undefined, + error: authValidityQuery.error, + }; +} diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts index 3517ad2b..ad050ef4 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts @@ -1,10 +1,7 @@ import { useQuery } from '@tanstack/react-query'; - -import { workspaces, workspacesByRepo } from '../api'; -import { useCoderAuth } from '../components/CoderProvider/CoderAuthProvider'; -import { useBackstageEndpoints } from './useBackstageEndpoints'; -import { CoderWorkspacesConfig } from './useCoderWorkspacesConfig'; -import { identityApiRef, useApi } from '@backstage/core-plugin-api'; +import { workspacesByRepo, workspaces } from '../api/queryOptions'; +import type { CoderWorkspacesConfig } from './useCoderWorkspacesConfig'; +import { useCoderClient } from './useCoderClient'; type QueryInput = Readonly<{ coderQuery: string; @@ -15,20 +12,13 @@ export function useCoderWorkspacesQuery({ coderQuery, workspacesConfig, }: QueryInput) { - const auth = useCoderAuth(); - const identity = useApi(identityApiRef); - const { baseUrl } = useBackstageEndpoints(); - const hasRepoData = workspacesConfig && workspacesConfig.repoUrl; + const client = useCoderClient(); + const hasRepoData = + workspacesConfig !== undefined && Boolean(workspacesConfig.repoUrl); const queryOptions = hasRepoData - ? workspacesByRepo({ - coderQuery, - identity, - auth, - baseUrl, - workspacesConfig, - }) - : workspaces({ coderQuery, identity, auth, baseUrl }); + ? workspacesByRepo({ client, coderQuery, workspacesConfig }) + : workspaces({ client, coderQuery }); return useQuery(queryOptions); } diff --git a/plugins/backstage-plugin-coder/src/plugin.ts b/plugins/backstage-plugin-coder/src/plugin.ts index 7de9929e..c6fb568e 100644 --- a/plugins/backstage-plugin-coder/src/plugin.ts +++ b/plugins/backstage-plugin-coder/src/plugin.ts @@ -1,14 +1,39 @@ import { createPlugin, createComponentExtension, + createApiFactory, + discoveryApiRef, + identityApiRef, } from '@backstage/core-plugin-api'; import { rootRouteRef } from './routes'; +import { CoderTokenAuth } from './api/CoderTokenAuth'; +import { CoderClient, coderClientApiRef } from './api/CoderClient'; +import { coderAuthApiRef } from './api/Auth'; export const coderPlugin = createPlugin({ id: 'coder', - routes: { - root: rootRouteRef, - }, + routes: { root: rootRouteRef }, + apis: [ + createApiFactory({ + api: coderAuthApiRef, + deps: {}, + factory: () => new CoderTokenAuth(), + }), + + createApiFactory({ + api: coderClientApiRef, + deps: { + identityApi: identityApiRef, + discoveryApi: discoveryApiRef, + authApi: coderAuthApiRef, + }, + factory: ({ discoveryApi, identityApi, authApi }) => { + return new CoderClient({ + apis: { discoveryApi, identityApi, authApi }, + }); + }, + }), + ], }); /** diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts index 2e0fa6fe..dbb2bfef 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts @@ -1,23 +1,39 @@ /* eslint-disable @backstage/no-undeclared-imports -- For test helpers only */ -import { ConfigReader } from '@backstage/core-app-api'; +import { ConfigReader, FrontendHostDiscovery } from '@backstage/core-app-api'; import { MockConfigApi, MockErrorApi } from '@backstage/test-utils'; import type { ScmIntegrationRegistry } from '@backstage/integration'; /* eslint-enable @backstage/no-undeclared-imports */ import { useEntity } from '@backstage/plugin-catalog-react'; +import { type CoderAppConfig } from '../components/CoderProvider'; import { - type CoderAppConfig, - type CoderAuth, - type CoderAuthStatus, -} from '../components/CoderProvider'; + CoderClient, + coderClientApiRef, + defaultCoderClientConfigOptions, +} from '../api/CoderClient'; import { CoderWorkspacesConfig, type YamlConfig, } from '../hooks/useCoderWorkspacesConfig'; -import { ScmIntegrationsApi } from '@backstage/integration-react'; - -import { API_ROUTE_PREFIX, ASSETS_ROUTE_PREFIX } from '../api'; -import { IdentityApi } from '@backstage/core-plugin-api'; +import { + ScmIntegrationsApi, + scmIntegrationsApiRef, +} from '@backstage/integration-react'; +import { + CoderTokenAuthUiStatus, + CoderTokenUiAuth, +} from '../hooks/useCoderTokenAuth'; +import { + type IdentityApi, + type ApiRef, + configApiRef, + DiscoveryApi, + discoveryApiRef, + errorApiRef, + identityApiRef, +} from '@backstage/core-plugin-api'; +import { CoderAuthApi, coderAuthApiRef } from '../api/Auth'; +import { CoderTokenAuth } from '../api/CoderTokenAuth'; /** * This is the key that Backstage checks from the entity data to determine the @@ -54,9 +70,11 @@ export const mockBackstageUrlRoot = 'http://localhost:7007'; * The actual endpoint to hit when trying to mock out a server request during * testing. */ -export const mockBackstageProxyEndpoint = `${mockBackstageUrlRoot}${API_ROUTE_PREFIX}`; +export const mockBackstageProxyEndpoint = + `${mockBackstageUrlRoot}/api/proxy${defaultCoderClientConfigOptions.proxyPrefix}${defaultCoderClientConfigOptions.apiRoutePrefix}` as const; -export const mockBackstageAssetsEndpoint = `${mockBackstageUrlRoot}${ASSETS_ROUTE_PREFIX}`; +export const mockBackstageAssetsEndpoint = + `${mockBackstageUrlRoot}/api/proxy${defaultCoderClientConfigOptions.assetsRoutePrefix}` as const; export const mockBearerToken = 'This-is-an-opaque-value-by-design'; export const mockCoderAuthToken = 'ZG0HRy2gGN-mXljc1s5FqtE8WUJ4sUc5X'; @@ -137,7 +155,7 @@ const authedState = { isAuthenticated: true, registerNewToken: jest.fn(), ejectToken: jest.fn(), -} as const satisfies Partial; +} as const satisfies Partial; const notAuthedState = { token: undefined, @@ -146,7 +164,7 @@ const notAuthedState = { isAuthenticated: false, registerNewToken: jest.fn(), ejectToken: jest.fn(), -} as const satisfies Partial; +} as const satisfies Partial; export const mockAuthStates = { authenticated: { @@ -193,7 +211,7 @@ export const mockAuthStates = { ...notAuthedState, status: 'deploymentUnavailable', }, -} as const satisfies Record; +} as const satisfies Record; export function getMockConfigApi() { return new MockConfigApi({ @@ -246,3 +264,134 @@ export function getMockIdentityApi(): IdentityApi { export function getMockSourceControl(): ScmIntegrationRegistry { return ScmIntegrationsApi.fromConfig(new ConfigReader({})); } + +export function getMockDiscoveryApi(): DiscoveryApi { + return FrontendHostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: mockBackstageUrlRoot, + }, + }), + ); +} + +type SetupCoderClientInputs = Readonly<{ + discoveryApi?: DiscoveryApi; + identityApi?: IdentityApi; + authApi?: CoderAuthApi; +}>; + +type SetupCoderClientResult = Readonly<{ + authApi: CoderAuthApi; + coderClientApi: CoderClient; +}>; + +const activeClients = new Set(); +afterEach(() => { + activeClients.forEach(client => client.cleanupClient()); + activeClients.clear(); +}); + +/** + * Gives back a Coder Client, its underlying auth implementation, and also + * handles cleanup for the Coder client between test runs. + * + * It is strongly recommended that you create all Coder + */ +export function setupCoderClient({ + authApi = getMockCoderTokenAuth(), + discoveryApi = getMockDiscoveryApi(), + identityApi = getMockIdentityApi(), +}: SetupCoderClientInputs): SetupCoderClientResult { + const mockCoderClientApi = new CoderClient({ + apis: { identityApi, discoveryApi, authApi }, + }); + + activeClients.add(mockCoderClientApi); + + return { + authApi, + coderClientApi: mockCoderClientApi, + }; +} + +/** + * Creates a list of mock Backstage API definitions that can be fed directly + * into some of the official Backstage test helpers. + * + * When trying to set up dependency injection for a Backstage test, this is the + * main test helper you should be using 99% of the time. + */ +export function getMockApiList(): readonly [ + ApiRef, + Partial, +][] { + const mockErrorApi = getMockErrorApi(); + const mockSourceControl = getMockSourceControl(); + const mockConfigApi = getMockConfigApi(); + const mockIdentityApi = getMockIdentityApi(); + const mockDiscoveryApi = getMockDiscoveryApi(); + + const { authApi, coderClientApi } = setupCoderClient({ + discoveryApi: mockDiscoveryApi, + identityApi: mockIdentityApi, + }); + + return [ + // APIs that Backstage ships with normally + [errorApiRef, mockErrorApi], + [scmIntegrationsApiRef, mockSourceControl], + [configApiRef, mockConfigApi], + [identityApiRef, mockIdentityApi], + [discoveryApiRef, mockDiscoveryApi], + + // Custom, Coder-specific APIs + [coderAuthApiRef, authApi], + [coderClientApiRef, coderClientApi], + ]; +} + +export function getMockLocalStorage( + initialData: Record = {}, +): Storage { + let dataStore: Map = new Map( + Object.entries(initialData), + ); + + return { + get length() { + return dataStore.size; + }, + + getItem: key => { + if (!dataStore.has(key)) { + return null; + } + + return dataStore.get(key) ?? null; + }, + + setItem: (key, value) => { + dataStore.set(key, value); + }, + + removeItem: key => { + dataStore.delete(key); + }, + + clear: () => { + dataStore = new Map(); + }, + + key: keyIndex => { + const keys = [...dataStore.keys()]; + return keys[keyIndex] ?? null; + }, + }; +} + +export function getMockCoderTokenAuth(): CoderTokenAuth { + return new CoderTokenAuth({ + localStorage: getMockLocalStorage(), + }); +} diff --git a/plugins/backstage-plugin-coder/src/testHelpers/server.ts b/plugins/backstage-plugin-coder/src/testHelpers/server.ts index 99db7c1b..dceefd22 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/server.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/server.ts @@ -20,7 +20,10 @@ import { mockBackstageProxyEndpoint as root, } from './mockBackstageData'; import type { Workspace, WorkspacesResponse } from '../typesConstants'; -import { CODER_AUTH_HEADER_KEY } from '../api'; +import { + UserLoginType, + defaultCoderClientConfigOptions, +} from '../api/CoderClient'; type RestResolver = ResponseResolver< RestRequest, @@ -33,6 +36,19 @@ export type RestResolverMiddleware = ( ) => RestResolver; const defaultMiddleware = [ + function validateCoderSessionToken(handler) { + return (req, res, ctx) => { + const headerKey = defaultCoderClientConfigOptions.authHeaderKey; + const token = req.headers.get(headerKey); + + if (token === mockCoderAuthToken) { + return handler(req, res, ctx); + } + + return res(ctx.status(401)); + }; + }, + function validateBearerToken(handler) { return (req, res, ctx) => { const tokenRe = /^Bearer (.+)$/; @@ -59,7 +75,7 @@ export function wrapInDefaultMiddleware( }, resolver); } -function wrappedGet( +export function wrappedGet( path: string, resolver: RestResolver, ): RestHandler { @@ -104,13 +120,13 @@ const mainTestHandlers: readonly RestHandler[] = [ ), // This is the dummy request used to verify a user's auth status - wrappedGet(`${root}/users/me`, (req, res, ctx) => { - const token = req.headers.get(CODER_AUTH_HEADER_KEY); - if (token === mockCoderAuthToken) { - return res(ctx.status(200)); - } - - return res(ctx.status(401)); + wrappedGet(`${root}/users/me/login-type`, (_req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + login_type: 'token', + }), + ); }), ]; diff --git a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx index 70afba5b..2c0263a3 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx +++ b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx @@ -11,30 +11,23 @@ import { import React from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { scmIntegrationsApiRef } from '@backstage/integration-react'; -import { - configApiRef, - errorApiRef, - identityApiRef, -} from '@backstage/core-plugin-api'; import { EntityProvider } from '@backstage/plugin-catalog-react'; import { - type CoderAuth, - type CoderAuthStatus, type CoderAppConfig, type CoderProviderProps, AuthContext, CoderAppConfigProvider, } from '../components/CoderProvider'; +import type { + CoderTokenUiAuth, + CoderTokenAuthUiStatus, +} from '../hooks/useCoderTokenAuth'; import { - getMockSourceControl, mockAppConfig, mockEntity, - getMockErrorApi, - getMockConfigApi, mockAuthStates, BackstageEntity, - getMockIdentityApi, + getMockApiList, } from './mockBackstageData'; import { CoderErrorBoundary } from '../plugin'; @@ -114,13 +107,13 @@ export function getMockQueryClient(): QueryClient { type MockAuthProps = Readonly< CoderProviderProps & { - auth?: CoderAuth; + auth?: CoderTokenUiAuth; /** * Shortcut property for injecting an auth object. Can conflict with the * auth property; if both are defined, authStatus is completely ignored */ - authStatus?: CoderAuthStatus; + authStatus?: CoderTokenAuthUiStatus; } >; @@ -150,7 +143,7 @@ type RenderHookAsCoderEntityOptions> = Omit< RenderHookOptions, 'wrapper' > & { - authStatus?: CoderAuthStatus; + authStatus?: CoderTokenAuthUiStatus; }; export const renderHookAsCoderEntity = async < @@ -161,24 +154,13 @@ export const renderHookAsCoderEntity = async < options?: RenderHookAsCoderEntityOptions, ): Promise> => { const { authStatus, ...delegatedOptions } = options ?? {}; - const mockErrorApi = getMockErrorApi(); - const mockSourceControl = getMockSourceControl(); - const mockConfigApi = getMockConfigApi(); - const mockIdentityApi = getMockIdentityApi(); const mockQueryClient = getMockQueryClient(); const renderHookValue = renderHook(hook, { ...delegatedOptions, wrapper: ({ children }) => { const mainMarkup = ( - + ; export async function renderInCoderEnvironment({ @@ -212,27 +194,12 @@ export async function renderInCoderEnvironment({ queryClient = getMockQueryClient(), appConfig = mockAppConfig, }: RenderInCoderEnvironmentInputs) { - /** - * Tried really hard to get renderInTestApp to work, but I couldn't figure out - * how to get it set up with custom config values (mainly for testing the - * backend endpoints). - * - * Manually setting up the config API to get around that - */ - const mockErrorApi = getMockErrorApi(); - const mockSourceControl = getMockSourceControl(); - const mockConfigApi = getMockConfigApi(); - const mockIdentityApi = getMockIdentityApi(); - const mainMarkup = ( - + /** + * @todo Look into replacing TestApiProvider + wrapInTestApp with + * renderInTestApp + */ + ; + export const DEFAULT_CODER_DOCS_LINK = 'https://coder.com/docs/v2/latest'; export const workspaceAgentStatusSchema = union([ diff --git a/plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.test.ts b/plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.test.ts new file mode 100644 index 00000000..42f92312 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.test.ts @@ -0,0 +1,204 @@ +import type { ReadonlyJsonValue } from '../typesConstants'; +import { + StateSnapshotManager, + defaultDidSnapshotsChange, +} from './StateSnapshotManager'; + +describe(`${defaultDidSnapshotsChange.name}`, () => { + type SampleInput = Readonly<{ + snapshotA: ReadonlyJsonValue; + snapshotB: ReadonlyJsonValue; + }>; + + it('Will detect when two JSON primitives are the same', () => { + const samples = [ + { snapshotA: true, snapshotB: true }, + { snapshotA: 'cat', snapshotB: 'cat' }, + { snapshotA: 2, snapshotB: 2 }, + { snapshotA: null, snapshotB: null }, + ] as const satisfies readonly SampleInput[]; + + for (const { snapshotA, snapshotB } of samples) { + expect(defaultDidSnapshotsChange(snapshotA, snapshotB)).toBe(false); + } + }); + + it('Will detect when two JSON primitives are different', () => { + const samples = [ + { snapshotA: true, snapshotB: false }, + { snapshotA: 'cat', snapshotB: 'dog' }, + { snapshotA: 2, snapshotB: 789 }, + { snapshotA: null, snapshotB: 'blah' }, + ] as const satisfies readonly SampleInput[]; + + for (const { snapshotA, snapshotB } of samples) { + expect(defaultDidSnapshotsChange(snapshotA, snapshotB)).toBe(true); + } + }); + + it('Will detect when a value flips from a primitive to an object (or vice versa)', () => { + expect(defaultDidSnapshotsChange(null, {})).toBe(true); + expect(defaultDidSnapshotsChange({}, null)).toBe(true); + }); + + it('Will reject numbers that changed by a very small floating-point epsilon', () => { + expect(defaultDidSnapshotsChange(3, 3 / 1.00000001)).toBe(false); + }); + + it('Will check array values one level deep', () => { + const snapshotA = [1, 2, 3]; + + const snapshotB = [...snapshotA]; + expect(defaultDidSnapshotsChange(snapshotA, snapshotB)).toBe(false); + + const snapshotC = [...snapshotA, 4]; + expect(defaultDidSnapshotsChange(snapshotA, snapshotC)).toBe(true); + + const snapshotD = [...snapshotA, {}]; + expect(defaultDidSnapshotsChange(snapshotA, snapshotD)).toBe(true); + }); + + it('Will check object values one level deep', () => { + const snapshotA = { cat: true, dog: true }; + + const snapshotB = { ...snapshotA, dog: true }; + expect(defaultDidSnapshotsChange(snapshotA, snapshotB)).toBe(false); + + const snapshotC = { ...snapshotA, bird: true }; + expect(defaultDidSnapshotsChange(snapshotA, snapshotC)).toBe(true); + + const snapshotD = { ...snapshotA, value: {} }; + expect(defaultDidSnapshotsChange(snapshotA, snapshotD)).toBe(true); + }); +}); + +describe(`${StateSnapshotManager.name}`, () => { + it('Lets external systems subscribe and unsubscribe to internal snapshot changes', () => { + type SampleData = Readonly<{ + snapshotA: ReadonlyJsonValue; + snapshotB: ReadonlyJsonValue; + }>; + + const samples = [ + { snapshotA: false, snapshotB: true }, + { snapshotA: 0, snapshotB: 1 }, + { snapshotA: 'cat', snapshotB: 'dog' }, + { snapshotA: null, snapshotB: 'neat' }, + { snapshotA: {}, snapshotB: { different: true } }, + { snapshotA: [], snapshotB: ['I have a value now!'] }, + ] as const satisfies readonly SampleData[]; + + for (const { snapshotA, snapshotB } of samples) { + const subscriptionCallback = jest.fn(); + const manager = new StateSnapshotManager({ + initialSnapshot: snapshotA, + didSnapshotsChange: defaultDidSnapshotsChange, + }); + + const unsubscribe = manager.subscribe(subscriptionCallback); + manager.updateSnapshot(snapshotB); + expect(subscriptionCallback).toHaveBeenCalledTimes(1); + expect(subscriptionCallback).toHaveBeenCalledWith(snapshotB); + + unsubscribe(); + manager.updateSnapshot(snapshotA); + expect(subscriptionCallback).toHaveBeenCalledTimes(1); + } + }); + + it('Lets user define a custom comparison algorithm during instantiation', () => { + type SampleData = Readonly<{ + snapshotA: ReadonlyJsonValue; + snapshotB: ReadonlyJsonValue; + compare: (A: ReadonlyJsonValue, B: ReadonlyJsonValue) => boolean; + }>; + + const exampleDeeplyNestedJson: ReadonlyJsonValue = { + value1: { + value2: { + value3: 'neat', + }, + }, + + value4: { + value5: [{ valueX: true }, { valueY: false }], + }, + }; + + const samples = [ + { + snapshotA: exampleDeeplyNestedJson, + snapshotB: { + ...exampleDeeplyNestedJson, + value4: { + value5: [{ valueX: false }, { valueY: false }], + }, + }, + compare: (A, B) => JSON.stringify(A) !== JSON.stringify(B), + }, + { + snapshotA: { tag: 'snapshot-993', value: 1 }, + snapshotB: { tag: 'snapshot-2004', value: 1 }, + compare: (A, B) => { + const recastA = A as Record; + const recastB = B as Record; + return recastA.tag !== recastB.tag; + }, + }, + ] as const satisfies readonly SampleData[]; + + for (const { snapshotA, snapshotB, compare } of samples) { + const subscriptionCallback = jest.fn(); + const manager = new StateSnapshotManager({ + initialSnapshot: snapshotA, + didSnapshotsChange: compare, + }); + + void manager.subscribe(subscriptionCallback); + manager.updateSnapshot(snapshotB); + expect(subscriptionCallback).toHaveBeenCalledWith(snapshotB); + } + }); + + it('Rejects new snapshots that are equivalent to old ones, and does NOT notify subscribers', () => { + type SampleData = Readonly<{ + snapshotA: ReadonlyJsonValue; + snapshotB: ReadonlyJsonValue; + }>; + + const samples = [ + { snapshotA: true, snapshotB: true }, + { snapshotA: 'kitty', snapshotB: 'kitty' }, + { snapshotA: null, snapshotB: null }, + { snapshotA: [], snapshotB: [] }, + { snapshotA: {}, snapshotB: {} }, + ] as const satisfies readonly SampleData[]; + + for (const { snapshotA, snapshotB } of samples) { + const subscriptionCallback = jest.fn(); + const manager = new StateSnapshotManager({ + initialSnapshot: snapshotA, + didSnapshotsChange: defaultDidSnapshotsChange, + }); + + void manager.subscribe(subscriptionCallback); + manager.updateSnapshot(snapshotB); + expect(subscriptionCallback).not.toHaveBeenCalled(); + } + }); + + it("Uses the default comparison algorithm if one isn't specified at instantiation", () => { + const snapshotA = { value: 'blah' }; + const snapshotB = { value: 'blah' }; + + const manager = new StateSnapshotManager({ + initialSnapshot: snapshotA, + }); + + const subscriptionCallback = jest.fn(); + void manager.subscribe(subscriptionCallback); + manager.updateSnapshot(snapshotB); + + expect(subscriptionCallback).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.ts b/plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.ts new file mode 100644 index 00000000..a109909d --- /dev/null +++ b/plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.ts @@ -0,0 +1,166 @@ +/** + * @file A helper class that simplifies the process of connecting mutable class + * values (such as the majority of values from API factories) with React's + * useSyncExternalStore hook. + * + * This should not be used directly from within React, but should instead be + * composed into other classes (such as API factories). Those classes can then + * be brought into React. + * + * As long as you can figure out how to turn the mutable values in some other + * class into an immutable snapshot, all you have to do is pass the new snapshot + * into this class. It will then take care of notifying subscriptions, while + * reconciling old/new snapshots to minimize needless re-renders. + */ +import type { ReadonlyJsonValue } from '../typesConstants'; + +type SubscriptionCallback = ( + snapshot: TSnapshot, +) => void; + +type DidSnapshotsChange = ( + oldSnapshot: TSnapshot, + newSnapshot: TSnapshot, +) => boolean; + +type SnapshotManagerOptions = Readonly<{ + initialSnapshot: TSnapshot; + + /** + * Lets you define a custom comparison strategy for detecting whether a + * snapshot has really changed in a way that should be reflected in the UI. + */ + didSnapshotsChange?: DidSnapshotsChange; +}>; + +interface SnapshotManagerApi { + subscribe: (callback: SubscriptionCallback) => () => void; + unsubscribe: (callback: SubscriptionCallback) => void; + getSnapshot: () => TSnapshot; + updateSnapshot: (newSnapshot: TSnapshot) => void; +} + +function areSameByReference(v1: unknown, v2: unknown) { + // Comparison looks wonky, but Object.is handles more edge cases than === + // for these kinds of comparisons, but it itself has an edge case + // with -0 and +0. Still need === to handle that comparison + return Object.is(v1, v2) || (v1 === 0 && v2 === 0); +} + +/** + * Favors shallow-ish comparisons (will check one level deep for objects and + * arrays, but no more) + */ +export function defaultDidSnapshotsChange( + oldSnapshot: TSnapshot, + newSnapshot: TSnapshot, +): boolean { + if (areSameByReference(oldSnapshot, newSnapshot)) { + return false; + } + + const oldIsPrimitive = + typeof oldSnapshot !== 'object' || oldSnapshot === null; + const newIsPrimitive = + typeof newSnapshot !== 'object' || newSnapshot === null; + + if (oldIsPrimitive && newIsPrimitive) { + const numbersAreWithinTolerance = + typeof oldSnapshot === 'number' && + typeof newSnapshot === 'number' && + Math.abs(oldSnapshot - newSnapshot) < 0.00005; + + if (numbersAreWithinTolerance) { + return false; + } + + return oldSnapshot !== newSnapshot; + } + + const changedFromObjectToPrimitive = !oldIsPrimitive && newIsPrimitive; + const changedFromPrimitiveToObject = oldIsPrimitive && !newIsPrimitive; + + if (changedFromObjectToPrimitive || changedFromPrimitiveToObject) { + return true; + } + + if (Array.isArray(oldSnapshot) && Array.isArray(newSnapshot)) { + const sameByShallowComparison = + oldSnapshot.length === newSnapshot.length && + oldSnapshot.every((element, index) => + areSameByReference(element, newSnapshot[index]), + ); + + return !sameByShallowComparison; + } + + const oldInnerValues: unknown[] = Object.values(oldSnapshot as Object); + const newInnerValues: unknown[] = Object.values(newSnapshot as Object); + + if (oldInnerValues.length !== newInnerValues.length) { + return true; + } + + for (const [index, value] of oldInnerValues.entries()) { + if (value !== newInnerValues[index]) { + return true; + } + } + + return false; +} + +/** + * @todo Might eventually make sense to give the class the ability to merge + * snapshots more surgically and maximize structural sharing (which should be + * safe since the snapshots are immutable). But we can worry about that when it + * actually becomes a performance issue + */ +export class StateSnapshotManager< + TSnapshot extends ReadonlyJsonValue = ReadonlyJsonValue, +> implements SnapshotManagerApi +{ + private subscriptions: Set>; + private didSnapshotsChange: DidSnapshotsChange; + private activeSnapshot: TSnapshot; + + constructor(options: SnapshotManagerOptions) { + const { initialSnapshot, didSnapshotsChange } = options; + + this.subscriptions = new Set(); + this.activeSnapshot = initialSnapshot; + this.didSnapshotsChange = didSnapshotsChange ?? defaultDidSnapshotsChange; + } + + private notifySubscriptions(): void { + const snapshotBinding = this.activeSnapshot; + this.subscriptions.forEach(cb => cb(snapshotBinding)); + } + + unsubscribe = (callback: SubscriptionCallback): void => { + this.subscriptions.delete(callback); + }; + + subscribe = (callback: SubscriptionCallback): (() => void) => { + this.subscriptions.add(callback); + return () => this.unsubscribe(callback); + }; + + getSnapshot = (): TSnapshot => { + return this.activeSnapshot; + }; + + updateSnapshot = (newSnapshot: TSnapshot): void => { + const snapshotsChanged = this.didSnapshotsChange( + this.activeSnapshot, + newSnapshot, + ); + + if (!snapshotsChanged) { + return; + } + + this.activeSnapshot = newSnapshot; + this.notifySubscriptions(); + }; +} diff --git a/yarn.lock b/yarn.lock index a60186cb..7b400535 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8714,9 +8714,9 @@ integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== "@types/react-dom@*", "@types/react-dom@^18", "@types/react-dom@^18.0.0": - version "18.2.21" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.21.tgz#b8c81715cebdebb2994378616a8d54ace54f043a" - integrity sha512-gnvBA/21SA4xxqNXEwNiVcP0xSGHh/gi1VhWv9Bl46a0ItbTT5nFY+G9VSQpaG/8N/qdJpJ+vftQ4zflTtnjLw== + version "18.2.25" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.25.tgz#2946a30081f53e7c8d585eb138277245caedc521" + integrity sha512-o/V48vf4MQh7juIKZU2QGDfli6p1+OOi5oXx36Hffpc9adsHeXjVp8rHuPkjd8VT8sOJ2Zp05HR7CdpGTIUFUA== dependencies: "@types/react" "*" @@ -8752,12 +8752,11 @@ "@types/react" "*" "@types/react@*", "@types/react@^16.13.1 || ^17.0.0", "@types/react@^16.13.1 || ^17.0.0 || ^18.0.0", "@types/react@^18": - version "18.2.64" - resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.64.tgz#3700fbb6b2fa60a6868ec1323ae4cbd446a2197d" - integrity sha512-MlmPvHgjj2p3vZaxbQgFUQFvD8QiZwACfGqEdDSWou5yISWxDQ4/74nCAwsUiX7UFLKZz3BbVSPj+YxeoGGCfg== + version "18.2.78" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.78.tgz#94aec453d0ccca909998a2b4b2fd78af15a7d2fe" + integrity sha512-qOwdPnnitQY4xKlKayt42q5W5UQrSHjgoXNVEtxeqdITJ99k4VXJOP3vt8Rkm9HmgJpH50UNU+rlqfkfWOqp0A== dependencies: "@types/prop-types" "*" - "@types/scheduler" "*" csstype "^3.0.2" "@types/request@^2.47.1", "@types/request@^2.48.8": @@ -8787,11 +8786,6 @@ resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== -"@types/scheduler@*": - version "0.16.8" - resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.8.tgz#ce5ace04cfeabe7ef87c0091e50752e36707deff" - integrity sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A== - "@types/semver@^7.3.12", "@types/semver@^7.5.0": version "7.5.7" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.7.tgz#326f5fdda70d13580777bcaa1bc6fa772a5aef0e" @@ -8906,6 +8900,11 @@ dependencies: "@types/node" "*" +"@types/ua-parser-js@^0.7.39": + version "0.7.39" + resolved "https://registry.yarnpkg.com/@types/ua-parser-js/-/ua-parser-js-0.7.39.tgz#832c58e460c9435e4e34bb866e85e9146e12cdbb" + integrity sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg== + "@types/unist@^2", "@types/unist@^2.0.0": version "2.0.10" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.10.tgz#04ffa7f406ab628f7f7e97ca23e290cd8ab15efc" @@ -9951,6 +9950,15 @@ axios@^1.0.0, axios@^1.4.0, axios@^1.6.0: form-data "^4.0.0" proxy-from-env "^1.1.0" +axios@^1.6.8: + version "1.6.8" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.8.tgz#66d294951f5d988a00e87a0ffb955316a619ea66" + integrity sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ== + dependencies: + follow-redirects "^1.15.6" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + axobject-query@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-3.2.1.tgz#39c378a6e3b06ca679f29138151e45b2b32da62a" @@ -11837,6 +11845,11 @@ dateformat@^3.0.3: resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae" integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q== +dayjs@^1.11.10: + version "1.11.10" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.10.tgz#68acea85317a6e164457d6d6947564029a6a16a0" + integrity sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ== + debounce@^1.2.0: version "1.2.1" resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5" @@ -13519,6 +13532,11 @@ follow-redirects@^1.0.0, follow-redirects@^1.14.0, follow-redirects@^1.15.4: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.5.tgz#54d4d6d062c0fa7d9d17feb008461550e3ba8020" integrity sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw== +follow-redirects@^1.15.6: + version "1.15.6" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" + integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== + for-each@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" @@ -21890,16 +21908,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -21973,7 +21982,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -21987,13 +21996,6 @@ strip-ansi@5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -22927,6 +22929,11 @@ typescript@~5.2.0: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.2.tgz#5ebb5e5a5b75f085f22bc3f8460fba308310fa78" integrity sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w== +ua-parser-js@^1.0.37: + version "1.0.37" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.37.tgz#b5dc7b163a5c1f0c510b08446aed4da92c46373f" + integrity sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ== + uc.micro@^1.0.1, uc.micro@^1.0.5: version "1.0.6" resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac" @@ -23797,7 +23804,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -23815,15 +23822,6 @@ wrap-ansi@^6.0.1: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"