From 4394ab6d0658f918d4979b4d8603ce9d9637098f Mon Sep 17 00:00:00 2001 From: "Bestony@Homelab" Date: Thu, 16 Jul 2026 15:57:43 +0800 Subject: [PATCH] feat(login): persist Remember Me preference --- cypress.config.ts | 1 + cypress/e2e/login.cy.ts | 100 +++++++++++++++++++++++-- src/containers/Login.test.tsx | 125 ++++++++++++++++++++++++++++++++ src/containers/Login.tsx | 62 ++++++++++++---- src/utils/StorageHelper.test.ts | 42 +++++++++++ src/utils/StorageHelper.ts | 35 +++++++++ 6 files changed, 344 insertions(+), 21 deletions(-) create mode 100644 src/containers/Login.test.tsx create mode 100644 src/utils/StorageHelper.test.ts diff --git a/cypress.config.ts b/cypress.config.ts index 864de163..2efda2de 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "cypress" export default defineConfig({ e2e: { + baseUrl: process.env.CYPRESS_BASE_URL || "http://localhost:4500", setupNodeEvents(on, config) { // implement node event listeners here }, diff --git a/cypress/e2e/login.cy.ts b/cypress/e2e/login.cy.ts index 789beeef..2176556f 100644 --- a/cypress/e2e/login.cy.ts +++ b/cypress/e2e/login.cy.ts @@ -1,8 +1,10 @@ import { CyHttpMessages } from "cypress/types/net-stubbing" import IncomingHttpRequest = CyHttpMessages.IncomingHttpRequest -const BASE_URL = 'http://localhost:4500' +const BASE_URL = Cypress.config('baseUrl') || 'http://localhost:4500' const API_URL = '*login' +const REMEMBER_ME_OPTION_KEY = 'CAPROVER_REMEMBER_ME_OPTION' +const AUTH_KEY = 'CAPROVER_AUTH_KEY' const FAKE_PASSWORD = 'fake-password' const EMPTY_PASSWORD_RESPONSE = {"status": 1000, "description": "password is empty.", "data": {}} const FAKE_PASSWORD_RESPONSE = {"status": 1105, "description": "Password is incorrect.", "data": {}} @@ -11,6 +13,14 @@ const REAL_PASSWORD_RESPONSE = {"status": 100, "description": "Login succeeded", let interceptFlag: boolean = true +const visitLoginPage = () => + cy.visit(BASE_URL, { + onBeforeLoad: (win) => { + win.localStorage.clear() + win.sessionStorage.clear() + }, + }) + describe('login page', () => { describe('without any password', () => { beforeEach(() => { @@ -29,7 +39,7 @@ describe('login page', () => { }) }) it('should not sent the request to the server', () => { - cy.visit(BASE_URL) + visitLoginPage() cy.get('button[type=submit]').click() cy.wait(1000).then(() => { expect(interceptFlag).to.equal(false) @@ -53,7 +63,7 @@ describe('login page', () => { }).as('logUser') }) it('should send the request to the server and display Password is incorrect', () => { - cy.visit(BASE_URL) + visitLoginPage() cy.get('input[type=password]').type(FAKE_PASSWORD, { force: true }) cy.get('button[type=submit]').click() cy.wait('@logUser').then(() => { @@ -67,14 +77,14 @@ describe('login page', () => { }) describe('trying to change the password display', () => { it('should change the display of the password to clear with mouse', () => { - cy.visit(BASE_URL) + visitLoginPage() cy.get('input[type=password]').as('passwordInput') cy.get('@passwordInput').type(REAL_PASSWORD) cy.get('.ant-input-suffix').click() cy.get("input[type='text']").should("have.value", REAL_PASSWORD) }) it('should change the display of the password to clear then hide with mouse', () => { - cy.visit(BASE_URL) + visitLoginPage() cy.get('input[type=password]').as('passwordInput') cy.get('@passwordInput').type(REAL_PASSWORD) cy.get('.ant-input-suffix').click() @@ -102,7 +112,7 @@ describe('login page', () => { }).as('logUser') }) it('should send the request to the server and redirect into the dashboard', () => { - cy.visit(BASE_URL) + visitLoginPage() cy.get('input[type=password]').type(REAL_PASSWORD, { force: true }) cy.get('button[type=submit]').click() cy.wait('@logUser').then(() => { @@ -129,7 +139,7 @@ describe('login page', () => { }).as('logUser') }) it('should send the request to the server and redirect into the dashboard', () => { - cy.visit(BASE_URL) + visitLoginPage() cy.get('input[type=password]').type(REAL_PASSWORD + "{enter}", { force: true }) cy.wait('@logUser').then(() => { expect(interceptFlag).to.equal(true) @@ -138,4 +148,80 @@ describe('login page', () => { }) }) }) + + describe('Remember Me preference', () => { + const openRememberMePanel = () => { + cy.contains('.ant-collapse-header', 'Remember Me').click() + } + + it('persists the selected option and restores it after reload', () => { + visitLoginPage() + openRememberMePanel() + cy.contains('.ant-radio-wrapper', 'Use localStorage').click() + + cy.window().then((win) => { + expect( + win.localStorage.getItem(REMEMBER_ME_OPTION_KEY) + ).to.equal('3') + }) + + cy.reload() + openRememberMePanel() + cy.get('input[type="radio"][value="3"]').should('be.checked') + }) + + it('stores a successful sessionStorage login in sessionStorage only', () => { + cy.intercept( + { + method: 'POST', + url: API_URL, + }, + { + statusCode: 200, + body: REAL_PASSWORD_RESPONSE, + } + ).as('rememberMeSessionLogin') + + visitLoginPage() + openRememberMePanel() + cy.contains('.ant-radio-wrapper', 'Use sessionStorage').click() + cy.get('input[type=password]').type(REAL_PASSWORD, { force: true }) + cy.get('button[type=submit]').click() + cy.wait('@rememberMeSessionLogin') + + cy.window().then((win) => { + expect(win.sessionStorage.getItem(AUTH_KEY)).to.equal( + REAL_PASSWORD_RESPONSE.data.token + ) + expect(win.localStorage.getItem(AUTH_KEY)).to.equal('') + }) + }) + + it('stores a successful localStorage login in localStorage only', () => { + cy.intercept( + { + method: 'POST', + url: API_URL, + }, + { + statusCode: 200, + body: REAL_PASSWORD_RESPONSE, + } + ).as('rememberMeLocalLogin') + + visitLoginPage() + openRememberMePanel() + cy.contains('.ant-radio-wrapper', 'Use localStorage').click() + cy.get('input[type=password]').type(REAL_PASSWORD, { force: true }) + cy.get('button[type=submit]').click() + cy.wait('@rememberMeLocalLogin') + + cy.window().then((win) => { + expect(win.localStorage.getItem(AUTH_KEY)).to.equal( + REAL_PASSWORD_RESPONSE.data.token + ) + expect(win.sessionStorage.getItem(AUTH_KEY)).to.equal('') + }) + }) + }) }) diff --git a/src/containers/Login.test.tsx b/src/containers/Login.test.tsx new file mode 100644 index 00000000..984c5f7e --- /dev/null +++ b/src/containers/Login.test.tsx @@ -0,0 +1,125 @@ +import { fireEvent, render, screen, cleanup } from '@testing-library/react' + +jest.mock('../utils/Language', () => ({ + isLanguageEnabled: false, + languagesOptions: [], + localize: (_key: string, message: string) => message, + getCurrentLanguageOption: () => ({ + value: 'en-US', + rtl: false, + antdLocale: {}, + messages: {}, + }), + setCurrentLanguageOption: jest.fn(), +})) + +import { + LOCAL_STORAGE, + NO_SESSION, + NormalLoginForm, + SESSION_STORAGE, +} from './Login' +import StorageHelper, { REMEMBER_ME_OPTION } from '../utils/StorageHelper' + +const getRadio = (option: number): HTMLInputElement => { + const radio = document.querySelector( + `input[type="radio"][value="${option}"]` + ) + if (!radio) throw new Error(`Radio option ${option} was not rendered`) + return radio as HTMLInputElement +} + +const openRememberMePanel = () => { + fireEvent.click(screen.getByText('Remember Me')) +} + +const renderLoginForm = (onLoginRequested = jest.fn()) => + render( + + ) + +describe('Remember Me login form', () => { + beforeAll(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: undefined, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + }), + }) + }) + + beforeEach(() => { + window.localStorage.clear() + }) + + afterEach(() => { + cleanup() + }) + + it.each([NO_SESSION, SESSION_STORAGE, LOCAL_STORAGE])( + 'persists option %i immediately when selected', + (option) => { + StorageHelper.setRememberMeOptionInLocalStorage( + option === NO_SESSION ? SESSION_STORAGE : NO_SESSION + ) + renderLoginForm() + openRememberMePanel() + + fireEvent.click(getRadio(option)) + + expect(StorageHelper.getRememberMeOptionFromLocalStorage()).toBe( + option + ) + } + ) + + it('restores the selected option after the form is remounted', () => { + renderLoginForm() + openRememberMePanel() + fireEvent.click(getRadio(LOCAL_STORAGE)) + expect(getRadio(LOCAL_STORAGE).checked).toBe(true) + + cleanup() + renderLoginForm() + openRememberMePanel() + + expect(getRadio(LOCAL_STORAGE).checked).toBe(true) + expect(getRadio(NO_SESSION).checked).toBe(false) + }) + + it.each([ + ['no stored option', undefined], + ['malformed JSON', '{not-json'], + ['unknown option', JSON.stringify(99)], + ])('defaults to NO_SESSION for %s', (_description, storedValue) => { + if (storedValue !== undefined) { + window.localStorage.setItem(REMEMBER_ME_OPTION, storedValue) + } + + renderLoginForm() + openRememberMePanel() + + expect(getRadio(NO_SESSION).checked).toBe(true) + expect(getRadio(SESSION_STORAGE).checked).toBe(false) + expect(getRadio(LOCAL_STORAGE).checked).toBe(false) + }) + + it('submits the currently selected option to the callback', () => { + const onLoginRequested = jest.fn() + const { container } = renderLoginForm(onLoginRequested) + openRememberMePanel() + fireEvent.click(getRadio(SESSION_STORAGE)) + + fireEvent.submit(container.querySelector('form') as HTMLFormElement) + + expect(onLoginRequested).toHaveBeenCalledTimes(1) + expect(onLoginRequested.mock.calls[0][2]).toBe(SESSION_STORAGE) + }) +}) diff --git a/src/containers/Login.tsx b/src/containers/Login.tsx index 74d9f2cc..5b560bae 100644 --- a/src/containers/Login.tsx +++ b/src/containers/Login.tsx @@ -5,21 +5,26 @@ import { Redirect, RouteComponentProps } from 'react-router' import ApiManager from '../api/ApiManager' import ErrorFactory from '../utils/ErrorFactory' import { isLanguageEnabled, localize } from '../utils/Language' +import Logger from '../utils/Logger' import StorageHelper from '../utils/StorageHelper' import Toaster from '../utils/Toaster' import Utils from '../utils/Utils' import ApiComponent from './global/ApiComponent' import LanguageSelector from './global/LanguageSelector' -const NO_SESSION = 1 -const SESSION_STORAGE = 2 -const LOCAL_STORAGE = 3 +export const NO_SESSION = 1 +export const SESSION_STORAGE = 2 +export const LOCAL_STORAGE = 3 + +const isRememberMeOption = (option: number | undefined): option is number => + option === NO_SESSION || + option === SESSION_STORAGE || + option === LOCAL_STORAGE export default class Login extends ApiComponent, any> { constructor(props: any) { super(props) this.state = { - loginOption: NO_SESSION, hasOtp: false, } } @@ -31,14 +36,18 @@ export default class Login extends ApiComponent, any> { Utils.deleteAllCookies() } - onLoginRequested(password: string, otp: string) { + onLoginRequested(password: string, otp: string, loginOption: number) { const self = this + Logger.dev(`Submitting login with Remember Me option: ${loginOption}`) this.apiManager .loginAndSavePassword(password, otp) .then(function (token) { - if (self.state.loginOption === SESSION_STORAGE) { + Logger.dev( + `Login succeeded with Remember Me option: ${loginOption}` + ) + if (loginOption === SESSION_STORAGE) { StorageHelper.setAuthKeyInSessionStorage(token) - } else if (self.state.loginOption === LOCAL_STORAGE) { + } else if (loginOption === LOCAL_STORAGE) { StorageHelper.setAuthKeyInLocalStorage(token) } self.props.history.push('/') @@ -85,8 +94,11 @@ export default class Login extends ApiComponent, any> { otp: string, loginOption: number ) => { - self.setState({ loginOption }) - self.onLoginRequested(password, otp) + self.onLoginRequested( + password, + otp, + loginOption + ) }} hasOtp={self.state.hasOtp} /> @@ -105,7 +117,7 @@ const radioStyle = { let lastSubmittedTime = 0 -class NormalLoginForm extends React.Component< +export class NormalLoginForm extends React.Component< any, { loginOption: number @@ -123,11 +135,18 @@ class NormalLoginForm extends React.Component< const params = Object.fromEntries(urlSearchParams.entries()) this.isDemo = !!params.demo } catch (e) { - console.error(e) + Logger.dev('Unable to parse login URL parameters') } + const savedLoginOption = + StorageHelper.getRememberMeOptionFromLocalStorage() + const loginOption = isRememberMeOption(savedLoginOption) + ? savedLoginOption + : NO_SESSION + Logger.dev(`Loaded Remember Me option: ${loginOption}`) + this.state = { - loginOption: NO_SESSION, + loginOption, passwordEntered: this.getDefaultPassword(), otpEntered: ``, } @@ -139,6 +158,9 @@ class NormalLoginForm extends React.Component< if (now - lastSubmittedTime < 300) return // avoid duplicate clicks lastSubmittedTime = now const self = this + Logger.dev( + `Submitting form with Remember Me option: ${self.state.loginOption}` + ) self.props.onLoginRequested( self.state.passwordEntered, self.state.otpEntered, @@ -222,9 +244,21 @@ class NormalLoginForm extends React.Component< > { - console.log(e.target.value) + const loginOption = Number(e.target.value) + if (!isRememberMeOption(loginOption)) { + Logger.dev( + `Ignoring invalid Remember Me option: ${loginOption}` + ) + return + } + Logger.dev( + `Remember Me option changed to: ${loginOption}` + ) + StorageHelper.setRememberMeOptionInLocalStorage( + loginOption + ) self.setState({ - loginOption: e.target.value, + loginOption, }) }} value={self.state.loginOption} diff --git a/src/utils/StorageHelper.test.ts b/src/utils/StorageHelper.test.ts new file mode 100644 index 00000000..e87f2902 --- /dev/null +++ b/src/utils/StorageHelper.test.ts @@ -0,0 +1,42 @@ +import StorageHelper, { REMEMBER_ME_OPTION } from './StorageHelper' + +describe('Remember Me option storage', () => { + beforeEach(() => { + window.localStorage.clear() + }) + + it('returns undefined when no option has been stored', () => { + expect( + StorageHelper.getRememberMeOptionFromLocalStorage() + ).toBeUndefined() + }) + + it.each([1, 2, 3])('stores and reads option %i', (option) => { + StorageHelper.setRememberMeOptionInLocalStorage(option) + + expect(window.localStorage.getItem(REMEMBER_ME_OPTION)).toBe( + JSON.stringify(option) + ) + expect(StorageHelper.getRememberMeOptionFromLocalStorage()).toBe(option) + }) + + it.each([ + ['malformed JSON', '{not-json'], + ['a JSON string', JSON.stringify('2')], + ['a fractional number', JSON.stringify(2.5)], + ['a boolean', JSON.stringify(true)], + ['null', 'null'], + ])('returns undefined for %s', (_description, value) => { + window.localStorage.setItem(REMEMBER_ME_OPTION, value) + + expect( + StorageHelper.getRememberMeOptionFromLocalStorage() + ).toBeUndefined() + }) + + it('does not persist a non-integer option', () => { + StorageHelper.setRememberMeOptionInLocalStorage(2.5) + + expect(window.localStorage.getItem(REMEMBER_ME_OPTION)).not.toBeTruthy() + }) +}) diff --git a/src/utils/StorageHelper.ts b/src/utils/StorageHelper.ts index 4d810df2..440fce5c 100644 --- a/src/utils/StorageHelper.ts +++ b/src/utils/StorageHelper.ts @@ -1,3 +1,5 @@ +import Logger from './Logger' + const fallbackNoOps = { getItem(t: string) { return '' @@ -14,6 +16,7 @@ const SIDER_COLLAPSED_STATE = 'CAPROVER_SIDER_COLLAPSED_STATE' const DARK_MODE = 'CAPROVER_DARK_MODE' const LANGUAGE = 'CAPROVER_LANGUAGE' const APP_PROJECT_SPLIT_RATIO = 'APP_PROJECT_SPLIT_RATIO' +export const REMEMBER_ME_OPTION = 'CAPROVER_REMEMBER_ME_OPTION' class StorageHelper { getAuthKeyFromStorage() { @@ -38,6 +41,38 @@ class StorageHelper { sessionStorage.setItem(AUTH_KEY, '') } + setRememberMeOptionInLocalStorage(option: number): void { + if (!Number.isInteger(option)) return + + try { + localStorage.setItem(REMEMBER_ME_OPTION, JSON.stringify(option)) + } catch (error) { + Logger.dev('Unable to persist Remember Me option') + } + } + + getRememberMeOptionFromLocalStorage(): number | undefined { + let storageValue = '' + try { + storageValue = localStorage.getItem(REMEMBER_ME_OPTION) || '' + } catch (error) { + Logger.dev('Unable to read Remember Me option') + return undefined + } + if (!storageValue) return undefined + + try { + const parsedValue: unknown = JSON.parse(storageValue) + return typeof parsedValue === 'number' && + Number.isInteger(parsedValue) + ? parsedValue + : undefined + } catch (error) { + Logger.dev('Unable to parse Remember Me option') + return undefined + } + } + setSiderCollapsedStateInLocalStorage(siderCollapsed: boolean) { localStorage.setItem( SIDER_COLLAPSED_STATE,