This is an automated email from the ASF dual-hosted git repository.

mcgilman pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new 9cad5678948 NIFI-15393 Preserve original URL when redirecting to login 
(#11868)
9cad5678948 is described below

commit 9cad56789489750626f5699aadd7092315dc1ea9
Author: Sejal Gupta <[email protected]>
AuthorDate: Fri Nov 6 21:09:19 2026 +0530

    NIFI-15393 Preserve original URL when redirecting to login (#11868)
---
 .../current-user/current-user.effects.spec.ts      | 135 +++++++++++++++++++++
 .../app/state/current-user/current-user.effects.ts |  23 +++-
 .../main/frontend/apps/nifi/src/app/state/index.ts |  20 +++
 3 files changed, 177 insertions(+), 1 deletion(-)

diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/current-user/current-user.effects.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/current-user/current-user.effects.spec.ts
new file mode 100644
index 00000000000..deb5036c35c
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/current-user/current-user.effects.spec.ts
@@ -0,0 +1,135 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 { TestBed } from '@angular/core/testing';
+import { Router } from '@angular/router';
+import { provideMockActions } from '@ngrx/effects/testing';
+import { provideMockStore } from '@ngrx/store/testing';
+import { Action } from '@ngrx/store';
+import { SessionStorageService } from '@nifi/shared';
+import { firstValueFrom, Observable, of } from 'rxjs';
+import { CurrentUserEffects } from './current-user.effects';
+import { loadCurrentUserSuccess, navigateToLogIn } from 
'./current-user.actions';
+import { CurrentUserService } from '../../service/current-user.service';
+import { AuthService } from '../../service/auth.service';
+import { ErrorHelper } from '../../service/error-helper.service';
+import { RETURN_URL } from '../index';
+import { initialState as currentUserInitialState } from 
'./current-user.reducer';
+
+describe('CurrentUserEffects', () => {
+    const loadSuccess = loadCurrentUserSuccess({
+        response: {
+            user: currentUserInitialState.user
+        }
+    });
+
+    async function setup(action: Action) {
+        const actions$: Observable<Action> = of(action);
+        const router = { navigate: vi.fn(), navigateByUrl: vi.fn() };
+
+        await TestBed.configureTestingModule({
+            providers: [
+                CurrentUserEffects,
+                provideMockActions(() => actions$),
+                provideMockStore({ initialState: {} }),
+                { provide: Router, useValue: router },
+                { provide: CurrentUserService, useValue: {} },
+                { provide: AuthService, useValue: {} },
+                { provide: ErrorHelper, useValue: {} }
+            ]
+        }).compileComponents();
+
+        return {
+            effects: TestBed.inject(CurrentUserEffects),
+            sessionStorage: TestBed.inject(SessionStorageService),
+            router
+        };
+    }
+
+    beforeEach(() => {
+        window.sessionStorage.clear();
+        window.location.hash = '';
+    });
+
+    afterEach(() => {
+        window.sessionStorage.clear();
+        window.location.hash = '';
+    });
+
+    describe('navigateToLogIn$', () => {
+        it('should store the requested route, including its query string, and 
navigate to login', async () => {
+            window.location.hash = '#/process-groups/abc?param=1';
+            const { effects, sessionStorage, router } = await 
setup(navigateToLogIn());
+
+            await firstValueFrom(effects.navigateToLogIn$);
+
+            
expect(sessionStorage.getItem<string>(RETURN_URL)).toBe('/process-groups/abc?param=1');
+            expect(router.navigate).toHaveBeenCalledWith(['/login']);
+        });
+
+        it.each(['/login', '/logout-complete', '/error', '/404'])('should not 
store %s', async (route) => {
+            window.location.hash = `#${route}`;
+            const { effects, sessionStorage, router } = await 
setup(navigateToLogIn());
+
+            await firstValueFrom(effects.navigateToLogIn$);
+
+            expect(sessionStorage.getItem<string>(RETURN_URL)).toBeNull();
+            expect(router.navigate).toHaveBeenCalledWith(['/login']);
+        });
+
+        it.each(['/login?returnTo=/settings', '/error?message=x', '/404?x=1'])(
+            'should not store %s ignoring the query string',
+            async (route) => {
+                window.location.hash = `#${route}`;
+                const { effects, sessionStorage } = await 
setup(navigateToLogIn());
+
+                await firstValueFrom(effects.navigateToLogIn$);
+
+                expect(sessionStorage.getItem<string>(RETURN_URL)).toBeNull();
+            }
+        );
+
+        it('should not store an empty hash', async () => {
+            const { effects, sessionStorage, router } = await 
setup(navigateToLogIn());
+
+            await firstValueFrom(effects.navigateToLogIn$);
+
+            expect(sessionStorage.getItem<string>(RETURN_URL)).toBeNull();
+            expect(router.navigate).toHaveBeenCalledWith(['/login']);
+        });
+    });
+
+    describe('restoreReturnUrl$', () => {
+        it('should navigate to the stored url and remove it', async () => {
+            const { effects, sessionStorage, router } = await 
setup(loadSuccess);
+            sessionStorage.setItem<string>(RETURN_URL, '/settings?tab=1');
+
+            await firstValueFrom(effects.restoreReturnUrl$);
+
+            
expect(router.navigateByUrl).toHaveBeenCalledWith('/settings?tab=1');
+            expect(sessionStorage.getItem<string>(RETURN_URL)).toBeNull();
+        });
+
+        it('should not navigate when no return url is stored', async () => {
+            const { effects, router } = await setup(loadSuccess);
+
+            await firstValueFrom(effects.restoreReturnUrl$);
+
+            expect(router.navigateByUrl).not.toHaveBeenCalled();
+        });
+    });
+});
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/current-user/current-user.effects.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/current-user/current-user.effects.ts
index 8bb50a54160..aa353d31985 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/current-user/current-user.effects.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/current-user/current-user.effects.ts
@@ -23,11 +23,12 @@ import { CurrentUserService } from 
'../../service/current-user.service';
 import { ErrorHelper } from '../../service/error-helper.service';
 import { concatLatestFrom } from '@ngrx/operators';
 import { Store } from '@ngrx/store';
-import { NiFiState } from '../index';
+import { isAuthenticatedRoute, NiFiState, RETURN_URL } from '../index';
 import { selectLogoutUri } from 
'../login-configuration/login-configuration.selectors';
 import { Router } from '@angular/router';
 import { AuthService } from '../../service/auth.service';
 import { HttpErrorResponse } from '@angular/common/http';
+import { SessionStorageService } from '@nifi/shared';
 
 @Injectable()
 export class CurrentUserEffects {
@@ -37,6 +38,7 @@ export class CurrentUserEffects {
     private userService = inject(CurrentUserService);
     private authService = inject(AuthService);
     private errorHelper = inject(ErrorHelper);
+    private sessionStorage = inject(SessionStorageService);
 
     loadCurrentUser$ = createEffect(() =>
         this.actions$.pipe(
@@ -75,12 +77,31 @@ export class CurrentUserEffects {
             this.actions$.pipe(
                 ofType(UserActions.navigateToLogIn),
                 tap(() => {
+                    const currentUrl = window.location.hash.substring(1);
+                    if (currentUrl.startsWith('/') && 
isAuthenticatedRoute(currentUrl)) {
+                        this.sessionStorage.setItem<string>(RETURN_URL, 
currentUrl);
+                    }
                     this.router.navigate(['/login']);
                 })
             ),
         { dispatch: false }
     );
 
+    restoreReturnUrl$ = createEffect(
+        () =>
+            this.actions$.pipe(
+                ofType(UserActions.loadCurrentUserSuccess),
+                tap(() => {
+                    const returnUrl = 
this.sessionStorage.getItem<string>(RETURN_URL);
+                    if (returnUrl) {
+                        this.sessionStorage.removeItem(RETURN_URL);
+                        this.router.navigateByUrl(returnUrl);
+                    }
+                })
+            ),
+        { dispatch: false }
+    );
+
     logout$ = createEffect(() =>
         this.actions$.pipe(
             ofType(UserActions.logout),
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/index.ts 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/index.ts
index 5394064ce3d..8960af22647 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/index.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/index.ts
@@ -54,6 +54,26 @@ import { canvasUiReducer } from 
'./canvas-ui/canvas-ui.reducer';
 import { emptyQueueFeatureKey, EmptyQueueState } from './empty-queue';
 import { emptyQueueReducer } from './empty-queue/empty-queue.reducer';
 
+/**
+ * Session storage key used to remember the route requested before redirecting 
to the login page.
+ */
+export const RETURN_URL = 'return-url';
+
+/**
+ * Routes that are accessible without authentication and must never be 
restored after logging in.
+ */
+const UNAUTHENTICATED_ROUTES: ReadonlySet<string> = new Set(['/login', 
'/logout-complete', '/error', '/404']);
+
+/**
+ * Determines whether the specified url requires authentication. Query strings 
are ignored when matching routes.
+ *
+ * @param url The url, such as '/settings?tab=1'
+ */
+export function isAuthenticatedRoute(url: string): boolean {
+    const pathname = url.split('?')[0];
+    return !UNAUTHENTICATED_ROUTES.has(pathname);
+}
+
 export interface NiFiState {
     [DEFAULT_ROUTER_FEATURENAME]: RouterReducerState;
     [errorFeatureKey]: ErrorState;

Reply via email to