mirror of
https://github.com/Unleash/unleash.git
synced 2025-06-18 01:18:23 +02:00
feat: redirect logic refactor (#9734)
This commit is contained in:
parent
d406420223
commit
d60ea1acd4
1
.github/workflows/e2e.frontend.yaml
vendored
1
.github/workflows/e2e.frontend.yaml
vendored
@ -13,6 +13,7 @@ jobs:
|
|||||||
- groups/groups.spec.ts
|
- groups/groups.spec.ts
|
||||||
- projects/access.spec.ts
|
- projects/access.spec.ts
|
||||||
- segments/segments.spec.ts
|
- segments/segments.spec.ts
|
||||||
|
- login/login.spec.ts
|
||||||
steps:
|
steps:
|
||||||
- name: Dump GitHub context
|
- name: Dump GitHub context
|
||||||
env:
|
env:
|
||||||
|
1
frontend/cypress/global.d.ts
vendored
1
frontend/cypress/global.d.ts
vendored
@ -21,6 +21,7 @@ declare namespace Cypress {
|
|||||||
interface Chainable {
|
interface Chainable {
|
||||||
runBefore(): Chainable;
|
runBefore(): Chainable;
|
||||||
|
|
||||||
|
do_login(user = AUTH_USER, password = AUTH_PASSWORD): Chainable;
|
||||||
login_UI(user = AUTH_USER, password = AUTH_PASSWORD): Chainable;
|
login_UI(user = AUTH_USER, password = AUTH_PASSWORD): Chainable;
|
||||||
logout_UI(): Chainable;
|
logout_UI(): Chainable;
|
||||||
|
|
||||||
|
80
frontend/cypress/integration/login/login.spec.ts
Normal file
80
frontend/cypress/integration/login/login.spec.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
///<reference path="../../global.d.ts" />
|
||||||
|
|
||||||
|
describe('login', { testIsolation: true }, () => {
|
||||||
|
const baseUrl = Cypress.config().baseUrl;
|
||||||
|
const randomId = String(Math.random()).split('.')[1];
|
||||||
|
const projectName = `unleash-e2e-login-project-${randomId}`;
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
cy.runBefore();
|
||||||
|
Cypress.session.clearAllSavedSessions();
|
||||||
|
cy.login_UI();
|
||||||
|
cy.createProject_API(projectName);
|
||||||
|
cy.logout_UI();
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
cy.deleteProject_API(projectName);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.login_UI();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is redirecting to /personal after first login', () => {
|
||||||
|
cy.visit('/');
|
||||||
|
cy.url().should('eq', `${baseUrl}/personal`);
|
||||||
|
cy.visit('/');
|
||||||
|
// "/" should again redirect to last "home" page
|
||||||
|
cy.url().should('eq', `${baseUrl}/personal`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is redirecting to last visited projects', () => {
|
||||||
|
cy.visit('/projects');
|
||||||
|
cy.visit('/');
|
||||||
|
cy.url().should((url) => url.startsWith(`${baseUrl}/projects`));
|
||||||
|
cy.contains('a', projectName).click();
|
||||||
|
cy.get(`h1 span`).should('not.have.class', 'skeleton');
|
||||||
|
cy.visit('/');
|
||||||
|
cy.url().should((url) =>
|
||||||
|
// last visited project
|
||||||
|
url.startsWith(`${baseUrl}/projects/${projectName}`),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is redirecting to other pages', () => {
|
||||||
|
cy.visit('/search');
|
||||||
|
cy.visit('/playground');
|
||||||
|
cy.visit('/');
|
||||||
|
cy.url().should('eq', `${baseUrl}/playground`);
|
||||||
|
cy.visit('/admin');
|
||||||
|
cy.visit('/applications'); // not one of main pages
|
||||||
|
cy.visit('/');
|
||||||
|
cy.url().should('eq', `${baseUrl}/admin`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears last visited page on manual logout', () => {
|
||||||
|
cy.visit('/search');
|
||||||
|
cy.get('[data-testid=HEADER_USER_AVATAR]').click();
|
||||||
|
cy.get('button').contains('Log out').click();
|
||||||
|
cy.url().should('eq', `${baseUrl}/login`);
|
||||||
|
cy.visit('/');
|
||||||
|
cy.url().should('eq', `${baseUrl}/login`);
|
||||||
|
cy.do_login();
|
||||||
|
cy.visit('/');
|
||||||
|
cy.url().should('eq', `${baseUrl}/personal`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remembers last visited page on next login', () => {
|
||||||
|
cy.visit('/insights');
|
||||||
|
cy.window().then((win) => {
|
||||||
|
win.sessionStorage.clear(); // not localStorage
|
||||||
|
win.location.reload();
|
||||||
|
});
|
||||||
|
cy.visit('/');
|
||||||
|
cy.url().should('eq', `${baseUrl}/login`);
|
||||||
|
cy.do_login();
|
||||||
|
cy.visit('/');
|
||||||
|
cy.url().should('eq', `${baseUrl}/insights`);
|
||||||
|
});
|
||||||
|
});
|
@ -24,28 +24,35 @@ export const runBefore = () => {
|
|||||||
disableActiveSplashScreens();
|
disableActiveSplashScreens();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const do_login = (
|
||||||
|
user = AUTH_USER,
|
||||||
|
password = AUTH_PASSWORD,
|
||||||
|
): Chainable<any> => {
|
||||||
|
cy.visit('/');
|
||||||
|
cy.wait(200);
|
||||||
|
cy.get("[data-testid='LOGIN_EMAIL_ID']").type(user);
|
||||||
|
|
||||||
|
if (AUTH_PASSWORD) {
|
||||||
|
cy.get("[data-testid='LOGIN_PASSWORD_ID']").type(password);
|
||||||
|
}
|
||||||
|
|
||||||
|
cy.get("[data-testid='LOGIN_BUTTON']").click();
|
||||||
|
|
||||||
|
// Wait for the login redirect to complete.
|
||||||
|
cy.get("[data-testid='HEADER_USER_AVATAR']");
|
||||||
|
|
||||||
|
if (document.querySelector("[data-testid='CLOSE_SPLASH']")) {
|
||||||
|
cy.get("[data-testid='CLOSE_SPLASH']").click();
|
||||||
|
}
|
||||||
|
|
||||||
|
return cy;
|
||||||
|
};
|
||||||
|
|
||||||
export const login_UI = (
|
export const login_UI = (
|
||||||
user = AUTH_USER,
|
user = AUTH_USER,
|
||||||
password = AUTH_PASSWORD,
|
password = AUTH_PASSWORD,
|
||||||
): Chainable<any> => {
|
): Chainable<any> => {
|
||||||
return cy.session(user, () => {
|
return cy.session(user, () => do_login(user, password));
|
||||||
cy.visit('/');
|
|
||||||
cy.wait(200);
|
|
||||||
cy.get("[data-testid='LOGIN_EMAIL_ID']").type(user);
|
|
||||||
|
|
||||||
if (AUTH_PASSWORD) {
|
|
||||||
cy.get("[data-testid='LOGIN_PASSWORD_ID']").type(password);
|
|
||||||
}
|
|
||||||
|
|
||||||
cy.get("[data-testid='LOGIN_BUTTON']").click();
|
|
||||||
|
|
||||||
// Wait for the login redirect to complete.
|
|
||||||
cy.get("[data-testid='HEADER_USER_AVATAR']");
|
|
||||||
|
|
||||||
if (document.querySelector("[data-testid='CLOSE_SPLASH']")) {
|
|
||||||
cy.get("[data-testid='CLOSE_SPLASH']").click();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createFeature_UI = (
|
export const createFeature_UI = (
|
||||||
|
@ -12,6 +12,7 @@ import {
|
|||||||
addFlexibleRolloutStrategyToFeature_UI,
|
addFlexibleRolloutStrategyToFeature_UI,
|
||||||
addUserIdStrategyToFeature_UI,
|
addUserIdStrategyToFeature_UI,
|
||||||
updateFlexibleRolloutStrategy_UI,
|
updateFlexibleRolloutStrategy_UI,
|
||||||
|
do_login,
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
} from './UI';
|
} from './UI';
|
||||||
import {
|
import {
|
||||||
@ -32,6 +33,7 @@ Cypress.on('window:before:load', (window) => {
|
|||||||
});
|
});
|
||||||
Cypress.Commands.add('runBefore', runBefore);
|
Cypress.Commands.add('runBefore', runBefore);
|
||||||
Cypress.Commands.add('login_UI', login_UI);
|
Cypress.Commands.add('login_UI', login_UI);
|
||||||
|
Cypress.Commands.add('do_login', do_login);
|
||||||
Cypress.Commands.add('createSegment_UI', createSegment_UI);
|
Cypress.Commands.add('createSegment_UI', createSegment_UI);
|
||||||
Cypress.Commands.add('deleteSegment_UI', deleteSegment_UI);
|
Cypress.Commands.add('deleteSegment_UI', deleteSegment_UI);
|
||||||
Cypress.Commands.add('deleteFeature_API', deleteFeature_API);
|
Cypress.Commands.add('deleteFeature_API', deleteFeature_API);
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { Suspense, useEffect } from 'react';
|
import { Suspense, useEffect } from 'react';
|
||||||
import { Route, Routes } from 'react-router-dom';
|
import { Route, Routes, useLocation } from 'react-router-dom';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import { FeedbackNPS } from 'component/feedback/FeedbackNPS/FeedbackNPS';
|
import { FeedbackNPS } from 'component/feedback/FeedbackNPS/FeedbackNPS';
|
||||||
import { LayoutPicker } from 'component/layout/LayoutPicker/LayoutPicker';
|
import { LayoutPicker } from 'component/layout/LayoutPicker/LayoutPicker';
|
||||||
@ -16,7 +16,7 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
|||||||
|
|
||||||
import MaintenanceBanner from './maintenance/MaintenanceBanner';
|
import MaintenanceBanner from './maintenance/MaintenanceBanner';
|
||||||
import { styled } from '@mui/material';
|
import { styled } from '@mui/material';
|
||||||
import { InitialRedirect } from './InitialRedirect';
|
import { InitialRedirect, useLastViewedPage } from './InitialRedirect';
|
||||||
import { InternalBanners } from './banners/internalBanners/InternalBanners';
|
import { InternalBanners } from './banners/internalBanners/InternalBanners';
|
||||||
import { ExternalBanners } from './banners/externalBanners/ExternalBanners';
|
import { ExternalBanners } from './banners/externalBanners/ExternalBanners';
|
||||||
import { LicenseBanner } from './banners/internalBanners/LicenseBanner';
|
import { LicenseBanner } from './banners/internalBanners/LicenseBanner';
|
||||||
@ -51,6 +51,9 @@ export const App = () => {
|
|||||||
|
|
||||||
const isLoggedIn = Boolean(user?.id);
|
const isLoggedIn = Boolean(user?.id);
|
||||||
|
|
||||||
|
const location = useLocation();
|
||||||
|
useLastViewedPage(location);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SWRProvider>
|
<SWRProvider>
|
||||||
<Suspense fallback={<Loader type='fullscreen' />}>
|
<Suspense fallback={<Loader type='fullscreen' />}>
|
||||||
|
@ -1,37 +1,59 @@
|
|||||||
import { useCallback, useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { type Location, Navigate } from 'react-router-dom';
|
||||||
import useProjects from 'hooks/api/getters/useProjects/useProjects';
|
import useProjects from 'hooks/api/getters/useProjects/useProjects';
|
||||||
import { useLastViewedProject } from 'hooks/useLastViewedProject';
|
import { useLastViewedProject } from 'hooks/useLastViewedProject';
|
||||||
import Loader from './common/Loader/Loader';
|
import Loader from './common/Loader/Loader';
|
||||||
import { getSessionStorageItem, setSessionStorageItem } from 'utils/storage';
|
import { useLocalStorageState } from 'hooks/useLocalStorageState';
|
||||||
|
import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser';
|
||||||
|
|
||||||
export const InitialRedirect = () => {
|
export const useLastViewedPage = (location?: Location) => {
|
||||||
const { lastViewed } = useLastViewedProject();
|
const [state, setState] = useLocalStorageState<string>(
|
||||||
const { projects, loading } = useProjects();
|
'lastViewedPage',
|
||||||
const navigate = useNavigate();
|
'/personal',
|
||||||
const sessionRedirect = getSessionStorageItem('login-redirect');
|
7 * 24 * 60 * 60 * 1000, // 7 days, left to promote seeing Personal dashboard from time to time
|
||||||
|
);
|
||||||
// Redirect based on project and last viewed
|
|
||||||
const getRedirect = useCallback(() => {
|
|
||||||
if (projects && lastViewed) {
|
|
||||||
return `/projects/${lastViewed}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return '/personal';
|
|
||||||
}, [lastViewed, projects]);
|
|
||||||
|
|
||||||
const redirect = () => {
|
|
||||||
navigate(sessionRedirect ?? getRedirect(), { replace: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSessionStorageItem('login-redirect');
|
if (location) {
|
||||||
redirect();
|
const page = [
|
||||||
}, [getRedirect]);
|
'/personal',
|
||||||
|
'/projects',
|
||||||
|
'/search',
|
||||||
|
'/playground',
|
||||||
|
'/insights',
|
||||||
|
'/admin',
|
||||||
|
].find(
|
||||||
|
(page) =>
|
||||||
|
page === location.pathname ||
|
||||||
|
location.pathname.startsWith(`/{page}/`),
|
||||||
|
);
|
||||||
|
if (page) {
|
||||||
|
setState(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [location]);
|
||||||
|
|
||||||
if (loading) {
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InitialRedirect = () => {
|
||||||
|
const { user, loading: isLoadingAuth } = useAuthUser();
|
||||||
|
const { loading: isLoadingProjects } = useProjects();
|
||||||
|
const isLoggedIn = Boolean(user?.id);
|
||||||
|
const lastViewedPage = useLastViewedPage();
|
||||||
|
const { lastViewed: lastViewedProject } = useLastViewedProject();
|
||||||
|
|
||||||
|
if (isLoadingAuth || isLoadingProjects) {
|
||||||
return <Loader type='fullscreen' />;
|
return <Loader type='fullscreen' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
if (!isLoggedIn) {
|
||||||
|
return <Navigate to='/login' replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastViewedPage === '/projects' && lastViewedProject) {
|
||||||
|
return <Navigate to={`/projects/${lastViewedProject}`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Navigate to={lastViewedPage} replace />;
|
||||||
};
|
};
|
||||||
|
@ -401,7 +401,11 @@ export const FeatureToggleListTable: FC = () => {
|
|||||||
bodyClass='no-padding'
|
bodyClass='no-padding'
|
||||||
header={
|
header={
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title='Search'
|
title={
|
||||||
|
flagsReleaseManagementUIEnabled
|
||||||
|
? 'Flags overview'
|
||||||
|
: 'Search'
|
||||||
|
}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
|
@ -50,7 +50,7 @@ export const StyledInnerContainer = styled('div')(({ theme }) => ({
|
|||||||
alignItems: 'start',
|
alignItems: 'start',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const StyledProjectTitle = styled('span')(({ theme }) => ({
|
export const StyledProjectTitle = styled('h1')(({ theme }) => ({
|
||||||
margin: 0,
|
margin: 0,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
fontSize: theme.typography.h1.fontSize,
|
fontSize: theme.typography.h1.fontSize,
|
||||||
|
@ -21,21 +21,24 @@ interface ISimpleAuthProps {
|
|||||||
|
|
||||||
const SimpleAuth: VFC<ISimpleAuthProps> = ({ authDetails, redirect }) => {
|
const SimpleAuth: VFC<ISimpleAuthProps> = ({ authDetails, redirect }) => {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
|
const [isPending, setIsPending] = useState(false);
|
||||||
const { refetchUser } = useAuthUser();
|
const { refetchUser } = useAuthUser();
|
||||||
const { emailAuth } = useAuthApi();
|
const { emailAuth } = useAuthApi();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { setToastApiError } = useToast();
|
const { setToastApiError } = useToast();
|
||||||
|
|
||||||
const handleSubmit: FormEventHandler<HTMLFormElement> = async (evt) => {
|
const handleSubmit: FormEventHandler<HTMLFormElement> = async (evt) => {
|
||||||
|
setIsPending(true);
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await emailAuth(authDetails.path, email);
|
await emailAuth(authDetails.path, email);
|
||||||
refetchUser();
|
await refetchUser();
|
||||||
navigate(redirect, { replace: true });
|
navigate(redirect);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setToastApiError(formatUnknownError(error));
|
setToastApiError(formatUnknownError(error));
|
||||||
}
|
}
|
||||||
|
setIsPending(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (e) => {
|
const handleChange: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
@ -81,6 +84,7 @@ const SimpleAuth: VFC<ISimpleAuthProps> = ({ authDetails, redirect }) => {
|
|||||||
color='primary'
|
color='primary'
|
||||||
className={styles.button}
|
className={styles.button}
|
||||||
data-testid={LOGIN_BUTTON}
|
data-testid={LOGIN_BUTTON}
|
||||||
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -58,9 +58,10 @@ export const useAuthEndpoint = (): IUseAuthEndpointOutput => {
|
|||||||
swrConfig,
|
swrConfig,
|
||||||
);
|
);
|
||||||
|
|
||||||
const refetchAuth = useCallback(() => {
|
const refetchAuth = useCallback(
|
||||||
mutate(USER_ENDPOINT_PATH).catch(console.warn);
|
async () => mutate(USER_ENDPOINT_PATH).catch(console.warn),
|
||||||
}, []);
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
|
@ -4,9 +4,10 @@ import { createLocalStorage } from '../utils/createLocalStorage';
|
|||||||
export const useLocalStorageState = <T extends object | string>(
|
export const useLocalStorageState = <T extends object | string>(
|
||||||
key: string,
|
key: string,
|
||||||
initialValue: T,
|
initialValue: T,
|
||||||
|
timeToLive?: number,
|
||||||
) => {
|
) => {
|
||||||
const { value: initialStoredValue, setValue: setStoredValue } =
|
const { value: initialStoredValue, setValue: setStoredValue } =
|
||||||
createLocalStorage<T>(key, initialValue);
|
createLocalStorage<T>(key, initialValue, timeToLive);
|
||||||
|
|
||||||
const [localValue, setLocalValue] = useState<T>(initialStoredValue);
|
const [localValue, setLocalValue] = useState<T>(initialStoredValue);
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ import { getLocalStorageItem, setLocalStorageItem } from './storage';
|
|||||||
export const createLocalStorage = <T extends object | string>(
|
export const createLocalStorage = <T extends object | string>(
|
||||||
key: string,
|
key: string,
|
||||||
initialValue: T,
|
initialValue: T,
|
||||||
|
timeToLive?: number,
|
||||||
) => {
|
) => {
|
||||||
const internalKey = `${basePath}:${key}:localStorage:v2`;
|
const internalKey = `${basePath}:${key}:localStorage:v2`;
|
||||||
const value = (() => {
|
const value = (() => {
|
||||||
@ -18,11 +19,11 @@ export const createLocalStorage = <T extends object | string>(
|
|||||||
if (newValue instanceof Function) {
|
if (newValue instanceof Function) {
|
||||||
const previousValue = getLocalStorageItem<T>(internalKey);
|
const previousValue = getLocalStorageItem<T>(internalKey);
|
||||||
const output = newValue(previousValue ?? initialValue);
|
const output = newValue(previousValue ?? initialValue);
|
||||||
setLocalStorageItem(internalKey, output);
|
setLocalStorageItem(internalKey, output, timeToLive);
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLocalStorageItem(internalKey, newValue);
|
setLocalStorageItem(internalKey, newValue, timeToLive);
|
||||||
return newValue;
|
return newValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -60,7 +60,7 @@ export function getLocalStorageItem<T>(key: string): T | undefined {
|
|||||||
export function setLocalStorageItem<T>(
|
export function setLocalStorageItem<T>(
|
||||||
key: string,
|
key: string,
|
||||||
value: T | undefined = undefined,
|
value: T | undefined = undefined,
|
||||||
timeToLive?: number,
|
timeToLive?: number, // milliseconds
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const item: Expirable<T> = {
|
const item: Expirable<T> = {
|
||||||
@ -80,7 +80,7 @@ export function setLocalStorageItem<T>(
|
|||||||
export function setSessionStorageItem<T>(
|
export function setSessionStorageItem<T>(
|
||||||
key: string,
|
key: string,
|
||||||
value: T | undefined = undefined,
|
value: T | undefined = undefined,
|
||||||
timeToLive?: number,
|
timeToLive?: number, // milliseconds
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const item: Expirable<T> = {
|
const item: Expirable<T> = {
|
||||||
|
Loading…
Reference in New Issue
Block a user