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,