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;