diff --git a/conf/nginx/server-base.conf.jnj b/conf/nginx/server-base.conf.jnj
index 75d1f6228..0440870c8 100644
--- a/conf/nginx/server-base.conf.jnj
+++ b/conf/nginx/server-base.conf.jnj
@@ -47,7 +47,7 @@ location / {
# Show new UI if cookie or default is set to "true"/"react"
if ($use_new_ui ~* "^(true|react)$") {
# catch new static paths and direct to /index.html
- rewrite ^(/overview|/organization|/repository|/signin|/createaccount|/oauth-error|/oauth2/[^/]+/callback(/attach|/cli)?|/updateuser|/user/(.*)) /index.html break;
+ rewrite ^(/overview|/organization|/repository|/signin|/createaccount|/oauth-error|/oauth/localapp|/oauth2/[^/]+/callback(/attach|/cli)?|/updateuser|/user/(.*)) /index.html break;
}
# Show old UI if not using React
@@ -57,7 +57,7 @@ location / {
}
# Capture traffic that needs to go to web_app, see /web.py
-location ~* ^(/config|/csrf_token|/oauth1|/oauth2|/webhooks|/keys|/.well-known|/customtrigger|/authrepoemail|/confirm|/userfiles/(.*)|/health/(.*)|/status|/_internal_ping) {
+location ~* ^(/config|/csrf_token|/oauth/authorizeapp|/oauth/authorize/assignuser|/oauth1|/oauth2|/webhooks|/keys|/.well-known|/customtrigger|/authrepoemail|/confirm|/userfiles/(.*)|/health/(.*)|/status|/_internal_ping) {
proxy_pass http://web_app_server;
}
diff --git a/web/cypress/e2e/org-oauth.cy.ts b/web/cypress/e2e/org-oauth.cy.ts
index 3599a5203..8d2dd8eac 100644
--- a/web/cypress/e2e/org-oauth.cy.ts
+++ b/web/cypress/e2e/org-oauth.cy.ts
@@ -613,7 +613,7 @@ describe('Organization OAuth Applications', () => {
cy.get('[data-testid="generate-token-button"]').should('not.be.disabled');
});
- it('should open OAuth authorization in new tab when generating token', () => {
+ it('should open OAuth authorization in popup window when generating token', () => {
cy.visit('/organization/testorg?tab=OAuthApplications');
cy.wait('@getOrg');
cy.wait('@getOAuthApplications');
@@ -668,7 +668,7 @@ describe('Organization OAuth Applications', () => {
// Check form properties
expect(capturedFormAction).to.include('/oauth/authorizeapp');
expect(capturedFormMethod.toLowerCase()).to.equal('post');
- expect(capturedFormTarget).to.equal('_blank');
+ expect(capturedFormTarget).to.equal('oauth_authorization');
// Check form data
expect(capturedFormData.client_id).to.exist;
@@ -677,6 +677,100 @@ describe('Organization OAuth Applications', () => {
});
});
+ it('should display token in React modal after generation (not redirect to Angular)', () => {
+ cy.visit('/organization/testorg?tab=OAuthApplications');
+ cy.wait('@getOrg');
+ cy.wait('@getOAuthApplications');
+
+ cy.contains('test-app').click();
+ cy.get('[data-testid="generate-token-tab"]').click();
+ cy.wait('@getCurrentUser');
+ cy.wait('@getConfig');
+
+ // Select scopes
+ cy.get('[data-testid="scope-repo:read"]').check();
+
+ // Click generate token to open modal
+ cy.get('[data-testid="generate-token-button"]').click();
+
+ // Stub window.open and postMessage to simulate OAuth flow
+ cy.window().then((win) => {
+ // Stub window.open to return a fake popup
+ const fakePopup = {
+ closed: false,
+ close: cy.stub(),
+ };
+ cy.stub(win, 'open').returns(fakePopup);
+
+ // Stub form submit to simulate OAuth callback
+ const submitStub = cy.stub(win.HTMLFormElement.prototype, 'submit');
+ submitStub.callsFake(function () {
+ // Simulate OAuth callback by posting message
+ setTimeout(() => {
+ win.postMessage(
+ {
+ type: 'OAUTH_TOKEN_GENERATED',
+ token: 'test-access-token-123456',
+ scope: 'repo:read',
+ state: null,
+ },
+ win.location.origin,
+ );
+ }, 100);
+ });
+ });
+
+ // Click authorize in modal
+ cy.get('[role="dialog"]').contains('Authorize Application').click();
+
+ // CRITICAL: Verify token modal appears in React UI
+ cy.contains('Access Token Generated', {timeout: 5000}).should('exist');
+ cy.contains('Your access token has been successfully generated').should(
+ 'exist',
+ );
+
+ // Verify token is displayed in the ClipboardCopy input
+ cy.get('.pf-v5-c-clipboard-copy input').should(
+ 'have.value',
+ 'test-access-token-123456',
+ );
+
+ // Verify user is still in React UI (no redirect to Angular)
+ cy.url().should('include', 'localhost');
+ cy.url().should('not.include', '/oauth/localapp');
+ cy.url().should('include', '/organization/testorg');
+ });
+
+ it('should handle popup blocked scenario gracefully', () => {
+ cy.visit('/organization/testorg?tab=OAuthApplications');
+ cy.wait('@getOrg');
+ cy.wait('@getOAuthApplications');
+
+ cy.contains('test-app').click();
+ cy.get('[data-testid="generate-token-tab"]').click();
+ cy.wait('@getCurrentUser');
+ cy.wait('@getConfig');
+
+ // Select scopes
+ cy.get('[data-testid="scope-repo:read"]').check();
+
+ // Click generate token to open modal
+ cy.get('[data-testid="generate-token-button"]').click();
+
+ // Stub window.open to return null (popup blocked) BEFORE clicking authorize
+ cy.window().then((win) => {
+ cy.stub(win, 'open').returns(null);
+ });
+
+ // Click authorize in modal
+ cy.get('[role="dialog"]').contains('Authorize Application').click();
+
+ // Verify PatternFly warning alert appears (not browser alert)
+ cy.contains(
+ 'Popup was blocked by your browser. Please allow popups for this site and try again.',
+ ).should('exist');
+ });
+
it('should handle user assignment functionality', () => {
cy.visit('/organization/testorg?tab=OAuthApplications');
cy.wait('@getOrg');
@@ -703,6 +797,82 @@ describe('Organization OAuth Applications', () => {
'Assign token',
);
});
+
+ it('should assign token with correct parameter placement (query string)', () => {
+ // Mock configuration with ASSIGN_OAUTH_TOKEN feature
+ cy.intercept('GET', '/config', (req) =>
+ req.reply((res) => {
+ res.body.features = {
+ ...res.body.features,
+ ASSIGN_OAUTH_TOKEN: true,
+ };
+ res.body.config.LOCAL_OAUTH_HANDLER = '/oauth/localapp';
+ res.body.config.PREFERRED_URL_SCHEME = 'http';
+ res.body.config.SERVER_HOSTNAME = 'localhost:8080';
+ return res;
+ }),
+ ).as('getConfig');
+
+ // Mock the assignuser OAuth endpoint with success response
+ cy.intercept('POST', '/oauth/authorize/assignuser*', {
+ statusCode: 200,
+ body: {
+ message: 'Token assigned successfully',
+ },
+ }).as('assignToken');
+
+ cy.visit('/organization/testorg?tab=OAuthApplications');
+ cy.wait('@getOrg');
+ cy.wait('@getOAuthApplications');
+
+ cy.contains('test-app').click();
+ cy.get('[data-testid="generate-token-tab"]').click();
+ cy.wait('@getCurrentUser');
+ cy.wait('@getConfig');
+
+ // Click assign another user
+ cy.get('[data-testid="assign-user-button"]').click();
+
+ // Search for user (user2 exists in seed data)
+ cy.get('#entity-search-input').type('user2');
+
+ // Select user from results
+ cy.contains('user2').click();
+
+ // Select scopes
+ cy.get('[data-testid="scope-repo:read"]').check();
+ cy.get('[data-testid="scope-repo:write"]').check();
+
+ // Click assign token button
+ cy.get('[data-testid="generate-token-button"]').click();
+
+ // Authorization modal should appear
+ cy.get('[role="dialog"]').should('be.visible');
+
+ // Click assign token button in modal to trigger fetch request
+ cy.get('[role="dialog"]').contains('Assign token').click();
+
+ // Wait for the assign token request and verify it was called
+ cy.wait('@assignToken').then((interception) => {
+ // Verify query parameters are present in URL
+ expect(interception.request.url).to.include('username=user2');
+ expect(interception.request.url).to.include('client_id=TEST123');
+ expect(interception.request.url).to.match(/scope=repo(%3A|:)read/);
+ expect(interception.request.url).to.include('redirect_uri=');
+ expect(interception.request.url).to.include('response_type=token');
+ expect(interception.request.url).to.include('format=json');
+ });
+
+ // Verify success alert appears (PatternFly alert)
+ cy.contains('Token assigned successfully').should('exist');
+
+ // Verify modal is closed
+ cy.get('[role="dialog"]').should('not.exist');
+
+ // Verify form is reset (user selection cleared)
+ cy.get('[data-testid="cancel-assign-button"]').should('not.exist');
+ cy.get('[data-testid="assign-user-button"]').should('exist');
+ });
});
describe('Delete OAuth Application', () => {
diff --git a/web/cypress/e2e/update-user.cy.ts b/web/cypress/e2e/update-user.cy.ts
index fab309565..646927ecc 100644
--- a/web/cypress/e2e/update-user.cy.ts
+++ b/web/cypress/e2e/update-user.cy.ts
@@ -65,7 +65,7 @@ describe('Update User Component', () => {
cy.get('button[type="submit"]').should('contain.text', 'Confirm Username');
});
- it.only('successfully confirms username', () => {
+ it('successfully confirms username', () => {
cy.intercept('GET', '/api/v1/user/', {
statusCode: 200,
body: {
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 3a82b2bb4..e9dec5ace 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -10,6 +10,7 @@ import {CreateAccount} from 'src/routes/CreateAccount/CreateAccount';
import UpdateUser from 'src/routes/UpdateUser/UpdateUser';
import {OAuthCallbackHandler} from 'src/routes/OAuthCallback/OAuthCallbackHandler';
import {OAuthError} from 'src/routes/OAuthCallback/OAuthError';
+import OAuthLocalHandler from 'src/routes/OAuthLocalHandler';
import {StandaloneMain} from 'src/routes/StandaloneMain';
import {ThemeProvider} from './contexts/ThemeContext';
@@ -26,6 +27,7 @@ export default function App() {
} />
} />
} />
+ } />
}
diff --git a/web/src/components/sidebar/QuaySidebar.tsx b/web/src/components/sidebar/QuaySidebar.tsx
index c9625e28b..3a7ebc90f 100644
--- a/web/src/components/sidebar/QuaySidebar.tsx
+++ b/web/src/components/sidebar/QuaySidebar.tsx
@@ -28,7 +28,7 @@ export function QuaySidebar() {
const quayConfig = useQuayConfig();
const routes: SideNavProps[] = [
{
- isSideNav: quayConfig?.config?.BRANDING.quay_io ? true : false,
+ isSideNav: quayConfig?.config?.BRANDING?.quay_io ? true : false,
navPath: NavigationPath.overviewList,
title: 'Overview',
component: ,
diff --git a/web/src/routes/OAuthLocalHandler/OAuthLocalHandler.tsx b/web/src/routes/OAuthLocalHandler/OAuthLocalHandler.tsx
new file mode 100644
index 000000000..bfd4319b1
--- /dev/null
+++ b/web/src/routes/OAuthLocalHandler/OAuthLocalHandler.tsx
@@ -0,0 +1,135 @@
+import React, {useEffect, useState} from 'react';
+import {useSearchParams, useNavigate} from 'react-router-dom';
+import {
+ Alert,
+ AlertVariant,
+ PageSection,
+ PageSectionVariants,
+ Spinner,
+} from '@patternfly/react-core';
+import TokenDisplayModal from 'src/components/modals/TokenDisplayModal';
+
+interface OAuthTokenData {
+ access_token: string;
+ scope: string;
+ state?: string;
+}
+
+export default function OAuthLocalHandler() {
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+ const [tokenData, setTokenData] = useState(null);
+ const [isTokenModalOpen, setIsTokenModalOpen] = useState(false);
+ const [error, setError] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ // Extract token from URL hash
+ const hash = window.location.hash.substring(1);
+ if (!hash) {
+ setError('Authorization was cancelled');
+ setIsLoading(false);
+ return;
+ }
+
+ const params = new URLSearchParams(hash);
+ const token = params.get('access_token');
+ const scope = params.get('scope');
+ const state = params.get('state');
+
+ if (!token) {
+ setError('No access token received');
+ setIsLoading(false);
+ return;
+ }
+
+ // Store token data
+ const data: OAuthTokenData = {
+ access_token: token,
+ scope: scope || '',
+ state: state || undefined,
+ };
+ setTokenData(data);
+
+ // Check if format=json requested (for API clients)
+ if (searchParams.get('format') === 'json') {
+ document.body.innerHTML = JSON.stringify({access_token: token});
+ setIsLoading(false);
+ return;
+ }
+
+ // Check if opened in popup window
+ if (window.opener && !window.opener.closed) {
+ try {
+ // Send token to parent window
+ window.opener.postMessage(
+ {
+ type: 'OAUTH_TOKEN_GENERATED',
+ token: token,
+ scope: scope,
+ state: state,
+ },
+ window.location.origin,
+ );
+
+ // Close popup after message sent
+ setTimeout(() => {
+ window.close();
+ }, 500);
+ } catch (err) {
+ console.error('Failed to communicate with parent window:', err);
+ // If postMessage fails, show modal in popup
+ setIsTokenModalOpen(true);
+ }
+ } else {
+ // Opened in same tab - show modal
+ setIsTokenModalOpen(true);
+ }
+
+ setIsLoading(false);
+ }, [searchParams]);
+
+ const handleModalClose = () => {
+ setIsTokenModalOpen(false);
+ // Navigate back to home or organization page
+ navigate('/organization');
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+ {error}
+
+
+ );
+ }
+
+ if (!tokenData) {
+ return null;
+ }
+
+ const scopes = tokenData.scope ? tokenData.scope.split(' ') : [];
+
+ return (
+ <>
+ {isTokenModalOpen && (
+
+ )}
+ >
+ );
+}
diff --git a/web/src/routes/OAuthLocalHandler/index.ts b/web/src/routes/OAuthLocalHandler/index.ts
new file mode 100644
index 000000000..3dc1616f5
--- /dev/null
+++ b/web/src/routes/OAuthLocalHandler/index.ts
@@ -0,0 +1 @@
+export {default} from './OAuthLocalHandler';
diff --git a/web/src/routes/OrganizationsList/Organization/Tabs/OAuthApplications/ManageOAuthApplicationTabs/GenerateTokenTab.tsx b/web/src/routes/OrganizationsList/Organization/Tabs/OAuthApplications/ManageOAuthApplicationTabs/GenerateTokenTab.tsx
index f83823bb6..f0d97da73 100644
--- a/web/src/routes/OrganizationsList/Organization/Tabs/OAuthApplications/ManageOAuthApplicationTabs/GenerateTokenTab.tsx
+++ b/web/src/routes/OrganizationsList/Organization/Tabs/OAuthApplications/ManageOAuthApplicationTabs/GenerateTokenTab.tsx
@@ -1,4 +1,4 @@
-import React, {useState} from 'react';
+import React, {useState, useEffect, useCallback} from 'react';
import {
Button,
Flex,
@@ -18,10 +18,13 @@ import {useCurrentUser} from 'src/hooks/UseCurrentUser';
import {useQuayConfig} from 'src/hooks/UseQuayConfig';
import {FormCheckbox} from 'src/components/forms/FormCheckbox';
import GenerateTokenAuthorizationModal from 'src/components/modals/GenerateTokenAuthorizationModal';
+import TokenDisplayModal from 'src/components/modals/TokenDisplayModal';
import EntitySearch from 'src/components/EntitySearch';
import {Entity} from 'src/resources/UserResource';
import {GlobalAuthState} from 'src/resources/AuthResource';
import {OAUTH_SCOPES, OAuthScope} from '../types';
+import {useAlerts} from 'src/hooks/UseAlerts';
+import {AlertVariant} from 'src/atoms/AlertState';
interface GenerateTokenTabProps {
application: IOAuthApplication | null;
@@ -36,8 +39,13 @@ export default function GenerateTokenTab(props: GenerateTokenTabProps) {
const [customUser, setCustomUser] = useState(false);
const [selectedUser, setSelectedUser] = useState(null);
const [isAuthModalOpen, setIsAuthModalOpen] = useState(false);
+ const [oauthPopup, setOauthPopup] = useState(null);
+ const [generatedToken, setGeneratedToken] = useState(null);
+ const [generatedScopes, setGeneratedScopes] = useState([]);
+ const [isTokenDisplayModalOpen, setIsTokenDisplayModalOpen] = useState(false);
const {user} = useCurrentUser();
const quayConfig = useQuayConfig();
+ const {addAlert} = useAlerts();
// Initialize form with all scopes set to false
const defaultValues: GenerateTokenFormData = {};
@@ -62,11 +70,8 @@ export default function GenerateTokenTab(props: GenerateTokenTabProps) {
};
const getUrl = (path: string): string => {
- const scheme =
- quayConfig?.config?.PREFERRED_URL_SCHEME ||
- window.location.protocol.replace(':', '');
- const hostname =
- quayConfig?.config?.SERVER_HOSTNAME || window.location.host;
+ const scheme = window.location.protocol.replace(':', '');
+ const hostname = window.location.host;
return `${scheme}://${hostname}${path}`;
};
@@ -84,6 +89,7 @@ export default function GenerateTokenTab(props: GenerateTokenTabProps) {
redirect_uri: getUrl(
quayConfig.config.LOCAL_OAUTH_HANDLER || '/oauth/localapp',
),
+ format: 'json',
});
return getUrl(`/oauth/authorize/assignuser?${params.toString()}`);
} else {
@@ -127,38 +133,133 @@ export default function GenerateTokenTab(props: GenerateTokenTabProps) {
setSelectedUser(null);
};
- const handleAuthModalConfirm = () => {
- // Use POST form submission for both assignment and generation
- const form = document.createElement('form');
- form.method = 'POST';
- form.action = generateUrl();
- form.target = '_blank';
- form.style.display = 'none';
+ const handleOAuthMessage = useCallback(
+ (event: MessageEvent) => {
+ // Verify origin for security
+ if (event.origin !== window.location.origin) {
+ console.warn('Received message from unexpected origin:', event.origin);
+ return;
+ }
- // Extract URL parameters and add them as form fields
- const url = new URL(generateUrl());
- url.searchParams.forEach((value, key) => {
- const input = document.createElement('input');
- input.type = 'hidden';
- input.name = key;
- input.value = value;
- form.appendChild(input);
- });
+ if (event.data.type === 'OAUTH_TOKEN_GENERATED') {
+ setGeneratedToken(event.data.token);
+ setGeneratedScopes(event.data.scope?.split(' ') || []);
+ setIsTokenDisplayModalOpen(true);
+ setIsAuthModalOpen(false);
- // Add CSRF token for POST request
- if (GlobalAuthState.csrfToken) {
- const csrfInput = document.createElement('input');
- csrfInput.type = 'hidden';
- csrfInput.name = '_csrf_token';
- csrfInput.value = GlobalAuthState.csrfToken;
- form.appendChild(csrfInput);
- }
+ // Clean up popup
+ if (oauthPopup && !oauthPopup.closed) {
+ oauthPopup.close();
+ }
+ setOauthPopup(null);
+ }
+ },
+ [oauthPopup],
+ );
- document.body.appendChild(form);
- form.submit();
- document.body.removeChild(form);
+ useEffect(() => {
+ window.addEventListener('message', handleOAuthMessage);
+ return () => {
+ window.removeEventListener('message', handleOAuthMessage);
+ };
+ }, [handleOAuthMessage]);
+ const handleAuthModalConfirm = async () => {
setIsAuthModalOpen(false);
+
+ const fullUrl = new URL(generateUrl());
+ const isAssignmentFlow = fullUrl.pathname.includes(
+ '/oauth/authorize/assignuser',
+ );
+
+ if (isAssignmentFlow) {
+ const formData = new FormData();
+ if (GlobalAuthState.csrfToken) {
+ formData.append('_csrf_token', GlobalAuthState.csrfToken);
+ }
+
+ try {
+ // Use relative URL for fetch to ensure proper proxy handling
+ const response = await fetch(fullUrl.pathname + fullUrl.search, {
+ method: 'POST',
+ body: formData,
+ credentials: 'include',
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to assign token');
+ }
+
+ const data = await response.json();
+ addAlert({
+ variant: AlertVariant.Success,
+ title: data.message || 'Token assigned successfully',
+ });
+ setCustomUser(false);
+ setSelectedUser(null);
+ } catch (error) {
+ console.error('Error assigning token:', error);
+ addAlert({
+ variant: AlertVariant.Failure,
+ title: 'Failed to assign token. Please try again.',
+ });
+ }
+ } else {
+ const form = document.createElement('form');
+ form.method = 'POST';
+ form.target = 'oauth_authorization';
+ form.style.display = 'none';
+ form.action = fullUrl.pathname;
+
+ fullUrl.searchParams.forEach((value, key) => {
+ const input = document.createElement('input');
+ input.type = 'hidden';
+ input.name = key;
+ input.value = value;
+ form.appendChild(input);
+ });
+
+ if (GlobalAuthState.csrfToken) {
+ const csrfInput = document.createElement('input');
+ csrfInput.type = 'hidden';
+ csrfInput.name = '_csrf_token';
+ csrfInput.value = GlobalAuthState.csrfToken;
+ form.appendChild(csrfInput);
+ }
+
+ const width = 600;
+ const height = 700;
+ const left = window.screen.width / 2 - width / 2;
+ const top = window.screen.height / 2 - height / 2;
+
+ const popup = window.open(
+ '',
+ 'oauth_authorization',
+ `width=${width},height=${height},left=${left},top=${top},scrollbars=yes,resizable=yes`,
+ );
+
+ if (!popup || popup.closed || typeof popup.closed === 'undefined') {
+ addAlert({
+ variant: AlertVariant.Warning,
+ title:
+ 'Popup was blocked by your browser. Please allow popups for this site and try again.',
+ });
+ return;
+ }
+
+ setOauthPopup(popup);
+
+ document.body.appendChild(form);
+ form.submit();
+ document.body.removeChild(form);
+
+ const checkPopupClosed = setInterval(() => {
+ if (popup.closed) {
+ clearInterval(checkPopupClosed);
+ setOauthPopup(null);
+ }
+ }, 500);
+ }
};
const handleAuthModalClose = () => {
@@ -294,6 +395,20 @@ export default function GenerateTokenTab(props: GenerateTokenTabProps) {
targetUsername={selectedUser?.name}
/>
)}
+
+ {generatedToken && (
+ {
+ setIsTokenDisplayModalOpen(false);
+ setGeneratedToken(null);
+ setGeneratedScopes([]);
+ }}
+ token={generatedToken}
+ applicationName={application.name}
+ scopes={generatedScopes}
+ />
+ )}
);
}
diff --git a/web/webpack.dev.js b/web/webpack.dev.js
index 74368d4a2..b28cd9a80 100644
--- a/web/webpack.dev.js
+++ b/web/webpack.dev.js
@@ -1,7 +1,7 @@
const Dotenv = require('dotenv-webpack');
const {merge} = require('webpack-merge');
const common = require('./webpack.common.js');
-const HOST = process.env.HOST || 'localhost';
+const HOST = process.env.HOST || '0.0.0.0';
const PORT = process.env.PORT || '9000';
module.exports = merge(common('development'), {
@@ -32,6 +32,10 @@ module.exports = merge(common('development'), {
target: 'http://localhost:8080',
logLevel: 'debug',
},
+ '/oauth': {
+ target: 'http://localhost:8080',
+ logLevel: 'debug',
+ },
},
},
module: {