diff --git a/plugins/auth/package.json b/plugins/auth/package.json index 6dadc78423..54e87cb7b1 100644 --- a/plugins/auth/package.json +++ b/plugins/auth/package.json @@ -47,9 +47,7 @@ "test": "backstage-cli package test" }, "dependencies": { - "@backstage/core-compat-api": "workspace:^", "@backstage/core-components": "workspace:^", - "@backstage/core-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", "@backstage/theme": "workspace:^", @@ -60,12 +58,11 @@ }, "devDependencies": { "@backstage/cli": "workspace:^", - "@backstage/core-app-api": "workspace:^", "@backstage/dev-utils": "workspace:^", "@backstage/frontend-defaults": "workspace:^", "@backstage/test-utils": "workspace:^", "@testing-library/jest-dom": "^6.0.0", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.0.0", "@types/react": "^18.0.0", "msw": "^1.0.0", diff --git a/plugins/auth/src/components/ConsentPage/ConsentPage.tsx b/plugins/auth/src/components/ConsentPage/ConsentPage.tsx index 3234450a1d..0f071025a2 100644 --- a/plugins/auth/src/components/ConsentPage/ConsentPage.tsx +++ b/plugins/auth/src/components/ConsentPage/ConsentPage.tsx @@ -13,8 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useCallback, useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; + import { Box, Button, @@ -38,13 +38,7 @@ import { EmptyState, ResponseErrorPanel, } from '@backstage/core-components'; -import { - alertApiRef, - useApi, - fetchApiRef, - discoveryApiRef, -} from '@backstage/core-plugin-api'; -import { isError } from '@backstage/errors'; +import { useConsentSession } from './useConsentSession'; const useStyles = makeStyles(theme => ({ authCard: { @@ -89,260 +83,164 @@ const useStyles = makeStyles(theme => ({ }, })); -interface Session { - id: string; - clientName?: string; - clientId: string; - redirectUri: string; - scopes?: string[]; - responseType?: string; - state?: string; - nonce?: string; - codeChallenge?: string; - codeChallengeMethod?: string; - expiresAt?: string; -} +const ConsentPageLayout = ({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) => ( + +
+ {children} + +); export const ConsentPage = () => { const classes = useStyles(); const { sessionId } = useParams<{ sessionId: string }>(); - const alertApi = useApi(alertApiRef); - const fetchApi = useApi(fetchApiRef); - const discoveryApi = useApi(discoveryApiRef); - const [session, setSession] = useState(null); - const [loading, setLoading] = useState(true); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); - const [completed, setCompleted] = useState< - | { - action: 'approve' | 'reject'; - } - | undefined - >(undefined); - - useEffect(() => { - const fetchSession = async () => { - if (!sessionId) return; - - try { - const baseUrl = await discoveryApi.getBaseUrl('auth'); - const response = await fetchApi.fetch( - `${baseUrl}/v1/sessions/${sessionId}`, - ); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = await response.json(); - setSession(data); - } catch (err) { - setError(isError(err) ? err.message : 'Failed to load consent request'); - } finally { - setLoading(false); - } - }; - - fetchSession(); - }, [sessionId, discoveryApi, fetchApi]); - - const handleAction = useCallback( - async (action: 'approve' | 'reject') => { - if (!session) return; - - setSubmitting(true); - try { - const baseUrl = await discoveryApi.getBaseUrl('auth'); - const response = await fetchApi.fetch( - `${baseUrl}/v1/sessions/${session.id}/${action}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - }, - ); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const result = await response.json(); - - setCompleted({ - action, - }); - - if (result.redirectUrl) { - window.location.href = result.redirectUrl; - } - } catch (err) { - alertApi.post({ - message: isError(err) ? err.message : `Failed to ${action} consent`, - severity: 'error', - }); - } finally { - setSubmitting(false); - } - }, - [session, discoveryApi, fetchApi, alertApi], - ); + const { state, handleAction } = useConsentSession({ sessionId }); if (!sessionId) { return ( - -
- - - - + + + ); } - if (loading) { + if (state.status === 'loading') { return ( - -
- - - - - - + + + + + ); } - if (error ?? !session) { + if (state.status === 'error') { return ( - -
- - - - + + + ); } - if (completed) { + if (state.status === 'completed') { return ( - -
- - - - - {completed.action === 'approve' ? ( - - ) : ( - - )} - - {completed.action === 'approve' - ? 'Authorization Approved' - : 'Authorization Denied'} - - - {completed.action === 'approve' - ? 'You have successfully authorized the application to access your Backstage account.' - : 'You have denied the application access to your Backstage account.'} - - - Redirecting to the application... - - - - - - - ); - } - - const appName = session.clientName ?? session.clientId; - - return ( - -
- + - - - - {appName} - - wants to access your Backstage account - - - - - - - } - className={classes.securityWarning} - > - - Security Notice: By authorizing this - application, you are granting it access to your Backstage - account. The application will receive an access token that - allows it to act on your behalf. + + {state.action === 'approve' ? ( + + ) : ( + + )} + + {state.action === 'approve' + ? 'Authorization Approved' + : 'Authorization Denied'} + + + {state.action === 'approve' + ? 'You have successfully authorized the application to access your Backstage account.' + : 'You have denied the application access to your Backstage account.'} - - - Callback URL: - - {session.redirectUri} - - - - - Make sure you trust this application and recognize the callback - URL above. Only authorize applications you trust. + Redirecting to the application... - - - - - - - + + ); + } + + const session = state.session; + const isSubmitting = state.status === 'submitting'; + const appName = session.clientName ?? session.clientId; + + return ( + + + + + + + {appName} + + wants to access your Backstage account + + + + + + + } + className={classes.securityWarning} + > + + Security Notice: By authorizing this application, + you are granting it access to your Backstage account. The + application will receive an access token that allows it to act on + your behalf. + + + + Callback URL: + + {session.redirectUri} + + + + + + Make sure you trust this application and recognize the callback + URL above. Only authorize applications you trust. + + + + + + + + + + ); }; diff --git a/plugins/auth/src/components/ConsentPage/useConsentSession.ts b/plugins/auth/src/components/ConsentPage/useConsentSession.ts new file mode 100644 index 0000000000..a19faae6fe --- /dev/null +++ b/plugins/auth/src/components/ConsentPage/useConsentSession.ts @@ -0,0 +1,144 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + useApi, + alertApiRef, + fetchApiRef, + discoveryApiRef, +} from '@backstage/frontend-plugin-api'; +import { useCallback } from 'react'; +import useAsync from 'react-use/esm/useAsync'; +import useAsyncFn from 'react-use/esm/useAsyncFn'; +import { isError } from '@backstage/errors'; + +interface Session { + id: string; + clientName?: string; + clientId: string; + redirectUri: string; + scopes?: string[]; + responseType?: string; + state?: string; + nonce?: string; + codeChallenge?: string; + codeChallengeMethod?: string; + expiresAt?: string; +} + +type ConsentState = + | { status: 'loading' } + | { status: 'error'; error: string } + | { status: 'loaded'; session: Session } + | { status: 'submitting'; session: Session; action: 'approve' | 'reject' } + | { status: 'completed'; action: 'approve' | 'reject' }; + +export const useConsentSession = (opts: { sessionId?: string }) => { + const alertApi = useApi(alertApiRef); + const fetchApi = useApi(fetchApiRef); + const discoveryApi = useApi(discoveryApiRef); + const { sessionId } = opts; + + const sessionState = useAsync(async () => { + if (!sessionId) { + throw new Error('Session ID is missing'); + } + + const baseUrl = await discoveryApi.getBaseUrl('auth'); + const response = await fetchApi.fetch( + `${baseUrl}/v1/sessions/${sessionId}`, + ); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return (await response.json()) as Session; + }, [sessionId]); + + const [actionState, handleActionInternal] = useAsyncFn( + async (action: 'approve' | 'reject', session: Session) => { + const baseUrl = await discoveryApi.getBaseUrl('auth'); + const response = await fetchApi.fetch( + `${baseUrl}/v1/sessions/${session.id}/${action}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const result = await response.json(); + + if (result.redirectUrl) { + window.location.href = result.redirectUrl; + } + + return { action, redirectUrl: result.redirectUrl }; + }, + [discoveryApi, fetchApi], + ); + + const getConsentState = (): ConsentState => { + if (actionState.value) { + return { status: 'completed', action: actionState.value.action }; + } + if (actionState.loading && sessionState.value) { + return { + status: 'submitting', + session: sessionState.value, + action: 'approve', // This will be set properly when called + }; + } + if (sessionState.error) { + return { + status: 'error', + error: isError(sessionState.error) + ? sessionState.error.message + : 'Failed to load consent request', + }; + } + if (sessionState.value) { + return { status: 'loaded', session: sessionState.value }; + } + return { status: 'loading' }; + }; + + const state = getConsentState(); + return { + state, + handleAction: useCallback( + async (action: 'approve' | 'reject') => { + if (state.status !== 'loaded') return; + + try { + await handleActionInternal(action, state.session); + } catch (err) { + alertApi.post({ + message: isError(err) ? err.message : `Failed to ${action} consent`, + severity: 'error', + }); + } + }, + [state, handleActionInternal, alertApi], + ), + }; +}; diff --git a/plugins/auth/src/plugin.tsx b/plugins/auth/src/plugin.tsx index 9b52960f4c..4ed5d57223 100644 --- a/plugins/auth/src/plugin.tsx +++ b/plugins/auth/src/plugin.tsx @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { compatWrapper } from '@backstage/core-compat-api'; import { createFrontendPlugin, PageBlueprint, @@ -24,8 +23,7 @@ export const AuthPage = PageBlueprint.make({ params: { path: '/oauth2', routeRef: rootRouteRef, - loader: () => - import('./components/Router').then(m => compatWrapper()), + loader: () => import('./components/Router').then(m => ), }, }); diff --git a/yarn.lock b/yarn.lock index 4d15a81ad2..9d3c3af1f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4269,10 +4269,7 @@ __metadata: resolution: "@backstage/plugin-auth@workspace:plugins/auth" dependencies: "@backstage/cli": "workspace:^" - "@backstage/core-app-api": "workspace:^" - "@backstage/core-compat-api": "workspace:^" "@backstage/core-components": "workspace:^" - "@backstage/core-plugin-api": "workspace:^" "@backstage/dev-utils": "workspace:^" "@backstage/errors": "workspace:^" "@backstage/frontend-defaults": "workspace:^" @@ -4283,18 +4280,22 @@ __metadata: "@material-ui/icons": "npm:^4.9.1" "@material-ui/lab": "npm:4.0.0-alpha.61" "@testing-library/jest-dom": "npm:^6.0.0" - "@testing-library/react": "npm:^14.0.0" + "@testing-library/react": "npm:^16.0.0" "@testing-library/user-event": "npm:^14.0.0" "@types/react": "npm:^18.0.0" msw: "npm:^1.0.0" react: "npm:^18.0.2" react-dom: "npm:^18.0.2" + react-router-dom: "npm:^6.3.0" react-use: "npm:^17.2.4" peerDependencies: "@types/react": ^17.0.0 || ^18.0.0 react: ^17.0.0 || ^18.0.0 react-dom: ^17.0.0 || ^18.0.0 react-router-dom: ^6.3.0 + peerDependenciesMeta: + "@types/react": + optional: true languageName: unknown linkType: soft @@ -19521,22 +19522,6 @@ __metadata: languageName: node linkType: hard -"@testing-library/dom@npm:^9.0.0": - version: 9.3.4 - resolution: "@testing-library/dom@npm:9.3.4" - dependencies: - "@babel/code-frame": "npm:^7.10.4" - "@babel/runtime": "npm:^7.12.5" - "@types/aria-query": "npm:^5.0.1" - aria-query: "npm:5.1.3" - chalk: "npm:^4.1.0" - dom-accessibility-api: "npm:^0.5.9" - lz-string: "npm:^1.5.0" - pretty-format: "npm:^27.0.2" - checksum: 10/510da752ea76f4a10a0a4e3a77917b0302cf03effe576cd3534cab7e796533ee2b0e9fb6fb11b911a1ebd7c70a0bb6f235bf4f816c9b82b95b8fe0cddfd10975 - languageName: node - linkType: hard - "@testing-library/jest-dom@npm:6.5.0": version: 6.5.0 resolution: "@testing-library/jest-dom@npm:6.5.0" @@ -19589,20 +19574,6 @@ __metadata: languageName: node linkType: hard -"@testing-library/react@npm:^14.0.0": - version: 14.3.1 - resolution: "@testing-library/react@npm:14.3.1" - dependencies: - "@babel/runtime": "npm:^7.12.5" - "@testing-library/dom": "npm:^9.0.0" - "@types/react-dom": "npm:^18.0.0" - peerDependencies: - react: ^18.0.0 - react-dom: ^18.0.0 - checksum: 10/83359dcdf9eaf067839f34604e1a181cbc14fc09f3a07672403700fcc6a900c4b8054ad1114fc24b4b9f89d84e2a09e1b7c9afce2306b1d4b4c9e30eb1cb12de - languageName: node - linkType: hard - "@testing-library/react@npm:^16.0.0": version: 16.3.0 resolution: "@testing-library/react@npm:16.3.0" @@ -24005,15 +23976,6 @@ __metadata: languageName: node linkType: hard -"aria-query@npm:5.1.3": - version: 5.1.3 - resolution: "aria-query@npm:5.1.3" - dependencies: - deep-equal: "npm:^2.0.5" - checksum: 10/e5da608a7c4954bfece2d879342b6c218b6b207e2d9e5af270b5e38ef8418f02d122afdc948b68e32649b849a38377785252059090d66fa8081da95d1609c0d2 - languageName: node - linkType: hard - "aria-query@npm:5.3.0": version: 5.3.0 resolution: "aria-query@npm:5.3.0" @@ -24030,7 +23992,7 @@ __metadata: languageName: node linkType: hard -"array-buffer-byte-length@npm:^1.0.0, array-buffer-byte-length@npm:^1.0.1, array-buffer-byte-length@npm:^1.0.2": +"array-buffer-byte-length@npm:^1.0.1, array-buffer-byte-length@npm:^1.0.2": version: 1.0.2 resolution: "array-buffer-byte-length@npm:1.0.2" dependencies: @@ -27790,32 +27752,6 @@ __metadata: languageName: node linkType: hard -"deep-equal@npm:^2.0.5": - version: 2.2.3 - resolution: "deep-equal@npm:2.2.3" - dependencies: - array-buffer-byte-length: "npm:^1.0.0" - call-bind: "npm:^1.0.5" - es-get-iterator: "npm:^1.1.3" - get-intrinsic: "npm:^1.2.2" - is-arguments: "npm:^1.1.1" - is-array-buffer: "npm:^3.0.2" - is-date-object: "npm:^1.0.5" - is-regex: "npm:^1.1.4" - is-shared-array-buffer: "npm:^1.0.2" - isarray: "npm:^2.0.5" - object-is: "npm:^1.1.5" - object-keys: "npm:^1.1.1" - object.assign: "npm:^4.1.4" - regexp.prototype.flags: "npm:^1.5.1" - side-channel: "npm:^1.0.4" - which-boxed-primitive: "npm:^1.0.2" - which-collection: "npm:^1.0.1" - which-typed-array: "npm:^1.1.13" - checksum: 10/1ce49d0b71d0f14d8ef991a742665eccd488dfc9b3cada069d4d7a86291e591c92d2589c832811dea182b4015736b210acaaebce6184be356c1060d176f5a05f - languageName: node - linkType: hard - "deep-equal@npm:~1.0.1": version: 1.0.1 resolution: "deep-equal@npm:1.0.1" @@ -29024,7 +28960,7 @@ __metadata: languageName: node linkType: hard -"es-get-iterator@npm:^1.0.2, es-get-iterator@npm:^1.1.3": +"es-get-iterator@npm:^1.0.2": version: 1.1.3 resolution: "es-get-iterator@npm:1.1.3" dependencies: @@ -31621,7 +31557,7 @@ __metadata: languageName: node linkType: hard -"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.2.2, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.3.0": +"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.3.0": version: 1.3.0 resolution: "get-intrinsic@npm:1.3.0" dependencies: @@ -33554,7 +33490,7 @@ __metadata: languageName: node linkType: hard -"is-array-buffer@npm:^3.0.2, is-array-buffer@npm:^3.0.4, is-array-buffer@npm:^3.0.5": +"is-array-buffer@npm:^3.0.4, is-array-buffer@npm:^3.0.5": version: 3.0.5 resolution: "is-array-buffer@npm:3.0.5" dependencies: @@ -34027,7 +33963,7 @@ __metadata: languageName: node linkType: hard -"is-regex@npm:^1.1.4, is-regex@npm:^1.2.1": +"is-regex@npm:^1.2.1": version: 1.2.1 resolution: "is-regex@npm:1.2.1" dependencies: @@ -34076,7 +34012,7 @@ __metadata: languageName: node linkType: hard -"is-shared-array-buffer@npm:^1.0.2, is-shared-array-buffer@npm:^1.0.4": +"is-shared-array-buffer@npm:^1.0.4": version: 1.0.4 resolution: "is-shared-array-buffer@npm:1.0.4" dependencies: @@ -43815,7 +43751,7 @@ __metadata: languageName: node linkType: hard -"regexp.prototype.flags@npm:^1.5.1, regexp.prototype.flags@npm:^1.5.3": +"regexp.prototype.flags@npm:^1.5.3": version: 1.5.4 resolution: "regexp.prototype.flags@npm:1.5.4" dependencies: @@ -45319,7 +45255,7 @@ __metadata: languageName: node linkType: hard -"side-channel@npm:^1.0.4, side-channel@npm:^1.0.6, side-channel@npm:^1.1.0": +"side-channel@npm:^1.0.6, side-channel@npm:^1.1.0": version: 1.1.0 resolution: "side-channel@npm:1.1.0" dependencies: @@ -49486,7 +49422,7 @@ __metadata: languageName: node linkType: hard -"which-boxed-primitive@npm:^1.0.2, which-boxed-primitive@npm:^1.1.0, which-boxed-primitive@npm:^1.1.1": +"which-boxed-primitive@npm:^1.1.0, which-boxed-primitive@npm:^1.1.1": version: 1.1.1 resolution: "which-boxed-primitive@npm:1.1.1" dependencies: @@ -49520,7 +49456,7 @@ __metadata: languageName: node linkType: hard -"which-collection@npm:^1.0.1, which-collection@npm:^1.0.2": +"which-collection@npm:^1.0.2": version: 1.0.2 resolution: "which-collection@npm:1.0.2" dependencies: @@ -49542,7 +49478,7 @@ __metadata: languageName: node linkType: hard -"which-typed-array@npm:^1.1.13, which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.18, which-typed-array@npm:^1.1.2": +"which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.18, which-typed-array@npm:^1.1.2": version: 1.1.19 resolution: "which-typed-array@npm:1.1.19" dependencies: