@@ -1,2 +0,0 @@ | |||
lesspass-site/ | |||
lesspass-web-component/ |
@@ -0,0 +1,24 @@ | |||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. | |||
# dependencies | |||
/node_modules | |||
/.pnp | |||
.pnp.js | |||
# testing | |||
/coverage | |||
# production | |||
/build | |||
/src/index.css | |||
# misc | |||
.DS_Store | |||
.env.local | |||
.env.development.local | |||
.env.test.local | |||
.env.production.local | |||
npm-debug.log* | |||
yarn-debug.log* | |||
yarn-error.log* |
@@ -0,0 +1,76 @@ | |||
{ | |||
"name": "lesspass-web-component", | |||
"version": "0.1.0", | |||
"private": true, | |||
"dependencies": { | |||
"@fortawesome/fontawesome-svg-core": "^1.2.28", | |||
"@fortawesome/free-brands-svg-icons": "^5.13.0", | |||
"@fortawesome/free-solid-svg-icons": "^5.13.0", | |||
"@fortawesome/react-fontawesome": "^0.1.9", | |||
"@fullhuman/postcss-purgecss": "^2.2.0", | |||
"@reduxjs/toolkit": "^1.3.6", | |||
"@testing-library/jest-dom": "^5.8.0", | |||
"@testing-library/react": "^10.0.4", | |||
"@testing-library/user-event": "^10.3.4", | |||
"@types/axios": "^0.14.0", | |||
"@types/i18next": "^13.0.0", | |||
"@types/jest": "^25.2.3", | |||
"@types/node": "^14.0.5", | |||
"@types/react": "^16.9.35", | |||
"@types/react-dom": "^16.9.8", | |||
"@types/react-i18next": "^8.1.0", | |||
"@types/react-redux": "^7.1.9", | |||
"@types/react-router-dom": "^5.1.5", | |||
"@types/redux-mock-store": "^1.0.2", | |||
"@types/redux-persist": "^4.3.1", | |||
"autoprefixer": "^9.8.0", | |||
"axios": "^0.19.2", | |||
"history": "^4.10.1", | |||
"i18next": "^19.4.4", | |||
"i18next-browser-languagedetector": "^4.2.0", | |||
"jest-environment-jsdom-sixteen": "^1.0.3", | |||
"lesspass": "^9.2.0", | |||
"npm-run-all": "^4.1.5", | |||
"postcss-cli": "^7.1.1", | |||
"postcss-discard-comments": "^4.0.2", | |||
"postcss-import": "^12.0.1", | |||
"react": "^16.13.1", | |||
"react-dom": "^16.13.1", | |||
"react-i18next": "^11.5.0", | |||
"react-redux": "^7.2.0", | |||
"react-router-dom": "^5.2.0", | |||
"react-scripts": "3.4.1", | |||
"redux-mock-store": "^1.5.4", | |||
"redux-persist": "^6.0.0", | |||
"tailwindcss": "^1.4.6", | |||
"ts-jest": "^26.0.0", | |||
"typescript": "~3.9.3" | |||
}, | |||
"scripts": { | |||
"start": "npm-run-all --parallel watch:css start:react", | |||
"build": "npm-run-all build:css build:react", | |||
"build:css": "postcss src/styles/tailwind.css --output src/index.css --env production", | |||
"watch:css": "postcss src/styles/tailwind.css --output src/index.css --watch", | |||
"start:react": "react-scripts start", | |||
"build:react": "react-scripts build", | |||
"test": "CI=true react-scripts test --env=jest-environment-jsdom-sixteen", | |||
"test:watch": "react-scripts test --env=jest-environment-jsdom-sixteen", | |||
"eject": "react-scripts eject", | |||
"prettier": "prettier --write \"src/**/*.js\"" | |||
}, | |||
"eslintConfig": { | |||
"extends": "react-app" | |||
}, | |||
"browserslist": { | |||
"production": [ | |||
">0.2%", | |||
"not dead", | |||
"not op_mini all" | |||
], | |||
"development": [ | |||
"last 1 chrome version", | |||
"last 1 firefox version", | |||
"last 1 safari version" | |||
] | |||
} | |||
} |
@@ -0,0 +1,25 @@ | |||
const purgecss = require("@fullhuman/postcss-purgecss")({ | |||
content: [ | |||
"./src/**/*.jsx", | |||
"./src/**/*.tsx", | |||
"./src/**/*.js", | |||
"./src/**/*.ts", | |||
"./public/index.html", | |||
], | |||
defaultExtractor: (content) => { | |||
const broadMatches = content.match(/[^<>"'`\s]*[^<>"'`\s:]/g) || []; | |||
const innerMatches = content.match(/[^<>"'`\s.()]*[^<>"'`\s.():]/g) || []; | |||
return broadMatches.concat(innerMatches); | |||
}, | |||
}); | |||
module.exports = { | |||
plugins: [ | |||
require("postcss-import"), | |||
require("tailwindcss"), | |||
require("autoprefixer"), | |||
...(process.env.NODE_ENV === "production" | |||
? [purgecss, require("postcss-discard-comments")({ removeAll: true })] | |||
: []), | |||
], | |||
}; |
@@ -0,0 +1,40 @@ | |||
<!DOCTYPE html> | |||
<html lang="en"> | |||
<head> | |||
<meta charset="utf-8" /> | |||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> | |||
<meta name="viewport" content="width=device-width, initial-scale=1" /> | |||
<meta name="theme-color" content="#3366cc" /> | |||
<meta name="description" content="LessPass stateless password manager" /> | |||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> | |||
<!-- | |||
manifest.json provides metadata used when your web app is installed on a | |||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/ | |||
--> | |||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> | |||
<!-- | |||
Notice the use of %PUBLIC_URL% in the tags above. | |||
It will be replaced with the URL of the `public` folder during the build. | |||
Only files inside the `public` folder can be referenced from the HTML. | |||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will | |||
work correctly both with client-side routing and a non-root public URL. | |||
Learn how to configure a non-root public URL by running `npm run build`. | |||
--> | |||
<title>LessPass</title> | |||
</head> | |||
<body> | |||
<noscript>You need to enable JavaScript to run this app.</noscript> | |||
<div id="root"></div> | |||
<!-- | |||
This HTML file is a template. | |||
If you open it directly in the browser, you will see an empty page. | |||
You can add webfonts, meta tags, or analytics to this file. | |||
The build step will place the bundled scripts into the <body> tag. | |||
To begin the development, run `npm start` or `yarn start`. | |||
To create a production bundle, use `npm run build` or `yarn build`. | |||
--> | |||
</body> | |||
</html> |
@@ -0,0 +1,25 @@ | |||
{ | |||
"short_name": "LessPass", | |||
"name": "LessPass stateless password manager", | |||
"icons": [ | |||
{ | |||
"src": "favicon.ico", | |||
"sizes": "64x64 32x32 24x24 16x16", | |||
"type": "image/x-icon" | |||
}, | |||
{ | |||
"src": "logo192.png", | |||
"type": "image/png", | |||
"sizes": "192x192" | |||
}, | |||
{ | |||
"src": "logo512.png", | |||
"type": "image/png", | |||
"sizes": "512x512" | |||
} | |||
], | |||
"start_url": ".", | |||
"display": "standalone", | |||
"theme_color": "#3366cc", | |||
"background_color": "#ffffff" | |||
} |
@@ -0,0 +1,3 @@ | |||
# https://www.robotstxt.org/robotstxt.html | |||
User-agent: * | |||
Disallow: / |
@@ -0,0 +1,258 @@ | |||
import React from "react"; | |||
import { Provider } from "react-redux"; | |||
import { render, screen, waitFor, fireEvent } from "@testing-library/react"; | |||
import { createMemoryHistory, MemoryHistory } from "history"; | |||
import App from "./App"; | |||
import { Router } from "react-router-dom"; | |||
import { I18nextProvider } from "react-i18next"; | |||
import { initI18n } from "./i18n"; | |||
import reduxStore from "./store"; | |||
import { fakeLocalStore } from "./services/localStore"; | |||
export const InitApp = ({ | |||
store, | |||
history = createMemoryHistory(), | |||
}: { | |||
store: Store; | |||
history?: MemoryHistory; | |||
}) => { | |||
return ( | |||
<Provider store={reduxStore}> | |||
<Router history={history}> | |||
<I18nextProvider i18n={initI18n(store)}> | |||
<App store={store} /> | |||
</I18nextProvider> | |||
</Router> | |||
</Provider> | |||
); | |||
}; | |||
beforeAll(() => { | |||
jest.useFakeTimers(); | |||
jest.runAllTimers(); | |||
}); | |||
afterAll(() => { | |||
jest.useRealTimers(); | |||
}); | |||
test("at startup master-password-input should be visible", async () => { | |||
render(<InitApp store={fakeLocalStore()} />); | |||
const masterPasswordInput = screen.queryByTestId( | |||
"master-password-input" | |||
) as HTMLInputElement; | |||
await waitFor(() => { | |||
expect(masterPasswordInput).toBeInTheDocument(); | |||
expect(masterPasswordInput.type).toBe("password"); | |||
expect(masterPasswordInput.value).toBe(""); | |||
}); | |||
}); | |||
test("at startup master-password-input should be focused", async () => { | |||
render(<InitApp store={fakeLocalStore()} />); | |||
const masterPasswordInput = screen.queryByTestId( | |||
"master-password-input" | |||
) as HTMLInputElement; | |||
await waitFor(() => { | |||
expect(masterPasswordInput).toBe(document.activeElement); | |||
}); | |||
}); | |||
test("at startup unlock page is not present if master password is in store", () => { | |||
render(<InitApp store={fakeLocalStore({ masterPassword: "password" })} />); | |||
const passwordGeneratorForm = screen.queryByTestId("password-generator-form"); | |||
expect(passwordGeneratorForm).toBeInTheDocument(); | |||
}); | |||
test("at startup save-master-password-checkbox should be visible", async () => { | |||
render(<InitApp store={fakeLocalStore()} />); | |||
const saveMasterPasswordCheckbox = screen.queryByTestId( | |||
"save-master-password-checkbox" | |||
) as HTMLInputElement; | |||
await waitFor(() => { | |||
expect(saveMasterPasswordCheckbox).toBeInTheDocument(); | |||
expect(saveMasterPasswordCheckbox.type).toBe("checkbox"); | |||
expect(saveMasterPasswordCheckbox.checked).toBe(true); | |||
}); | |||
}); | |||
test("hit enter on unlock page save master password in store", async () => { | |||
const store = fakeLocalStore(); | |||
expect(store.getItem("masterPassword")).not.toBe("hit enter"); | |||
render(<InitApp store={store} />); | |||
const masterPasswordInput = screen.queryByTestId( | |||
"master-password-input" | |||
) as HTMLInputElement; | |||
expect(masterPasswordInput).toBeInTheDocument(); | |||
fireEvent.change(masterPasswordInput, { target: { value: "hit enter" } }); | |||
fireEvent.submit(masterPasswordInput); | |||
expect(store.getItem("masterPassword")).toBe("hit enter"); | |||
await waitFor(() => { | |||
expect(screen.queryByTestId("password-generator-form")).toBeInTheDocument(); | |||
}); | |||
}); | |||
test("click save button and uncheck save-master-password-checkbox don't save it local storage", async () => { | |||
const store = fakeLocalStore(); | |||
expect(store.getItem("masterPassword")).toBeNull(); | |||
render(<InitApp store={store} />); | |||
const masterPasswordInput = screen.queryByTestId( | |||
"master-password-input" | |||
) as HTMLInputElement; | |||
expect(masterPasswordInput).toBeInTheDocument(); | |||
fireEvent.change(masterPasswordInput, { | |||
target: { value: "master password" }, | |||
}); | |||
const saveMasterPasswordCheckbox = screen.queryByTestId( | |||
"save-master-password-checkbox" | |||
) as HTMLInputElement; | |||
fireEvent.click(saveMasterPasswordCheckbox); | |||
const submitButton = screen.queryByTestId("unlock") as HTMLButtonElement; | |||
fireEvent.click(submitButton); | |||
expect(store.getItem("masterPassword")).toBeNull(); | |||
await waitFor(() => { | |||
expect(screen.queryByTestId("password-generator-form")).toBeInTheDocument(); | |||
}); | |||
}); | |||
test("lock button is present if master password in local storage and redirect to root url", async () => { | |||
render(<InitApp store={fakeLocalStore({ masterPassword: "password" })} />); | |||
const lockButton = screen.queryByTestId("lock-button") as HTMLButtonElement; | |||
expect(lockButton).toBeInTheDocument(); | |||
fireEvent.click(lockButton); | |||
const masterPasswordInput = screen.queryByTestId( | |||
"master-password-input" | |||
) as HTMLInputElement; | |||
await waitFor(() => { | |||
expect(masterPasswordInput).toBeInTheDocument(); | |||
}); | |||
}); | |||
test("click on unlock with save-master-password-checkbox false save value in localstorage", async () => { | |||
const store = fakeLocalStore(); | |||
render(<InitApp store={store} />); | |||
const saveMasterPasswordCheckbox = screen.queryByTestId( | |||
"save-master-password-checkbox" | |||
) as HTMLInputElement; | |||
fireEvent.click(saveMasterPasswordCheckbox); | |||
await waitFor(() => { | |||
expect(saveMasterPasswordCheckbox).toBeInTheDocument(); | |||
expect(saveMasterPasswordCheckbox.type).toBe("checkbox"); | |||
expect(saveMasterPasswordCheckbox.checked).toBe(false); | |||
expect(store.getItem("settings")).toBeNull(); | |||
}); | |||
const submitButton = screen.queryByTestId("unlock") as HTMLButtonElement; | |||
fireEvent.click(submitButton); | |||
await waitFor(() => { | |||
expect(store.getItem("settings")).toMatchObject({ | |||
saveMasterPassword: false, | |||
}); | |||
}); | |||
}); | |||
test("save-master-password-checkbox is unchecked if user already choose not to save master password", async () => { | |||
render( | |||
<InitApp | |||
store={fakeLocalStore({ settings: { saveMasterPassword: false } })} | |||
/> | |||
); | |||
const saveMasterPasswordCheckbox = screen.queryByTestId( | |||
"save-master-password-checkbox" | |||
) as HTMLInputElement; | |||
await waitFor(() => { | |||
expect(saveMasterPasswordCheckbox).toBeInTheDocument(); | |||
expect(saveMasterPasswordCheckbox.type).toBe("checkbox"); | |||
expect(saveMasterPasswordCheckbox.checked).toBe(false); | |||
}); | |||
}); | |||
test("click on unlock with save-master-password-checkbox false and remove master password from localstorage", async () => { | |||
const store = fakeLocalStore({ masterPassword: "password" }); | |||
render(<InitApp store={store} />); | |||
const lockButton = screen.queryByTestId("lock-button") as HTMLButtonElement; | |||
fireEvent.click(lockButton); | |||
const saveMasterPasswordCheckbox = screen.queryByTestId( | |||
"save-master-password-checkbox" | |||
) as HTMLInputElement; | |||
fireEvent.click(saveMasterPasswordCheckbox); | |||
const submitButton = screen.queryByTestId("unlock") as HTMLButtonElement; | |||
fireEvent.click(submitButton); | |||
await waitFor(() => { | |||
expect(store.getItem("masterPassword")).toBeNull(); | |||
}); | |||
}); | |||
test("routing pages unauth", async () => { | |||
const history = createMemoryHistory(); | |||
render( | |||
<InitApp | |||
history={history} | |||
store={fakeLocalStore({ masterPassword: "password" })} | |||
/> | |||
); | |||
expect(history.location.pathname).toBe("/"); | |||
fireEvent.click(screen.queryByText(/lesspass/i) as HTMLLinkElement); | |||
expect(history.location.pathname).toBe("/"); | |||
fireEvent.click(screen.queryByTestId("settings-link") as HTMLLinkElement); | |||
expect(history.location.pathname).toBe("/settings"); | |||
expect(screen.queryByText(/passwords/i)).toBeNull(); | |||
fireEvent.click(screen.queryByTestId("sign-in-link") as HTMLLinkElement); | |||
expect(history.location.pathname).toBe("/signIn"); | |||
fireEvent.click(screen.queryByTestId("register-link") as HTMLLinkElement); | |||
expect(history.location.pathname).toBe("/register"); | |||
expect(screen.queryByTestId("my-account-link")).toBeNull(); | |||
}); | |||
test("routing pages auth", async () => { | |||
const history = createMemoryHistory(); | |||
render( | |||
<InitApp | |||
history={history} | |||
store={fakeLocalStore({ | |||
masterPassword: "password", | |||
access_token: "access_token", | |||
})} | |||
/> | |||
); | |||
expect(history.location.pathname).toBe("/"); | |||
fireEvent.click(screen.queryByText(/lesspass/i) as HTMLLinkElement); | |||
expect(history.location.pathname).toBe("/"); | |||
fireEvent.click(screen.queryByTestId("settings-link") as HTMLLinkElement); | |||
expect(history.location.pathname).toBe("/settings"); | |||
await waitFor(() => { | |||
expect( | |||
screen.queryByText(/passwords/i) as HTMLLinkElement | |||
).toBeInTheDocument(); | |||
}); | |||
fireEvent.click(screen.queryByText(/passwords/i) as HTMLLinkElement); | |||
expect(history.location.pathname).toBe("/passwords"); | |||
expect(screen.queryByTestId("sign-in-link")).toBeNull(); | |||
expect(screen.queryByTestId("register-link")).toBeNull(); | |||
fireEvent.click(screen.queryByTestId("my-account-link") as HTMLLinkElement); | |||
expect(history.location.pathname).toBe("/my_account"); | |||
}); | |||
test("test i18n fr", async () => { | |||
render(<InitApp store={fakeLocalStore({ masterPassword: "password" })} />); | |||
expect(screen.queryByText(/se connecter/i)).toBeNull(); | |||
fireEvent.click(screen.queryByTestId("settings-link") as HTMLLinkElement); | |||
fireEvent.click(screen.queryByLabelText(/fr/i) as HTMLInputElement); | |||
expect(screen.queryByText(/se connecter/i)).toBeInTheDocument(); | |||
fireEvent.click(screen.queryByLabelText(/en/i) as HTMLInputElement); | |||
expect(screen.queryByText(/sign in/i)).toBeInTheDocument(); | |||
}); | |||
test("password generator page can't be reach if master password is not unlocked", async () => { | |||
const history = createMemoryHistory(); | |||
render(<InitApp history={history} store={fakeLocalStore()} />); | |||
const masterPasswordInput = screen.queryByTestId( | |||
"master-password-input" | |||
) as HTMLInputElement; | |||
fireEvent.change(masterPasswordInput, { | |||
target: { value: "p" }, | |||
}); | |||
fireEvent.click(screen.queryByText(/lesspass/i) as HTMLLinkElement); | |||
await waitFor(() => { | |||
expect(history.location.pathname).toBe("/unlock"); | |||
}); | |||
}); |
@@ -0,0 +1,120 @@ | |||
import React, { useState, useEffect } from "react"; | |||
import "./icons"; | |||
import { Switch, Route, useHistory, Redirect } from "react-router-dom"; | |||
import PasswordGeneratorPage from "./passwordGenerator/PasswordGeneratorPage"; | |||
import UnlockPage from "./unlock/UnlockPage"; | |||
import Nav from "./components/Nav"; | |||
import RegisterPage from "./auth/RegisterPage"; | |||
import SignInPage from "./auth/SignInPage"; | |||
import SettingsPage from "./settings/SettingsPage"; | |||
import defaultSettings from "./settings/defaultSettings"; | |||
import PasswordsPage from "./passwords/PasswordsPage"; | |||
import { useDispatch } from "react-redux"; | |||
import { signInSuccess, logout } from "./auth/authSlice"; | |||
import { | |||
MASTER_PASSWORD_KEY, | |||
SETTINGS_KEY, | |||
ACCESS_TOKEN, | |||
} from "./services/localStore"; | |||
function useLocalStore<T>( | |||
key: string, | |||
initialValue: T, | |||
store: Store | |||
): [T, (t: T) => void] { | |||
const [storedValue, setStoredValue] = useState<T>( | |||
(): T => { | |||
try { | |||
const value = (store.getItem(key) as unknown) as T; | |||
return value || initialValue; | |||
} catch (error) { | |||
return initialValue; | |||
} | |||
} | |||
); | |||
const setValue = (value: T) => { | |||
try { | |||
setStoredValue(value); | |||
store.setItem(key, (value as unknown) as Serializable); | |||
} catch (error) {} | |||
}; | |||
return [storedValue, setValue]; | |||
} | |||
const App = ({ store }: { store: Store }) => { | |||
const dispatch = useDispatch(); | |||
const history = useHistory(); | |||
const [masterPassword, setMasterPassword] = useState<MasterPassword>( | |||
() => (store.getItem(MASTER_PASSWORD_KEY) as string) || "" | |||
); | |||
const [settings, setSettings] = useLocalStore<Settings>( | |||
SETTINGS_KEY, | |||
defaultSettings, | |||
store | |||
); | |||
useEffect(() => { | |||
const access_token = store.getItem(ACCESS_TOKEN); | |||
if (access_token) { | |||
dispatch( | |||
signInSuccess({ | |||
access: access_token, | |||
} as SignInResponsePayload) | |||
); | |||
} else { | |||
dispatch(logout()); | |||
} | |||
}, [dispatch, store]); | |||
return ( | |||
<div> | |||
<Nav /> | |||
<Switch> | |||
<Route exact path="/unlock"> | |||
<UnlockPage | |||
settings={settings} | |||
unlock={(newMasterPassword, saveMasterPassword) => { | |||
if (saveMasterPassword) { | |||
store.setItem(MASTER_PASSWORD_KEY, newMasterPassword); | |||
} else { | |||
store.removeItem(MASTER_PASSWORD_KEY); | |||
} | |||
setMasterPassword(newMasterPassword); | |||
setSettings({ ...settings, saveMasterPassword }); | |||
history.push("/"); | |||
}} | |||
/> | |||
</Route> | |||
<Route exact path="/"> | |||
{masterPassword ? ( | |||
<PasswordGeneratorPage | |||
masterPassword={masterPassword} | |||
setMasterPassword={setMasterPassword} | |||
/> | |||
) : ( | |||
<Redirect to="/unlock" /> | |||
)} | |||
</Route> | |||
<Route exact path="/register"> | |||
<RegisterPage /> | |||
</Route> | |||
<Route exact path="/signIn"> | |||
<SignInPage /> | |||
</Route> | |||
<Route exact path="/settings"> | |||
<SettingsPage | |||
settings={settings} | |||
setSettings={setSettings} | |||
/> | |||
</Route> | |||
<Route exact path="/passwords"> | |||
<PasswordsPage /> | |||
</Route> | |||
</Switch> | |||
</div> | |||
); | |||
}; | |||
export default App; |
@@ -0,0 +1,55 @@ | |||
import React from "react"; | |||
import { render, screen, fireEvent, waitFor } from "@testing-library/react"; | |||
import RegisterForm from "./RegisterForm"; | |||
it("Registerform on submit", async () => { | |||
const mockOnSubmit = jest.fn(); | |||
render(<RegisterForm onSubmit={mockOnSubmit} />); | |||
const signInForm = screen.getByTestId("register-form"); | |||
expect(signInForm).not.toBe(null); | |||
const email = screen.getByTestId("email-input"); | |||
fireEvent.change(email, { | |||
target: { | |||
value: "contact@example.org", | |||
}, | |||
}); | |||
const password = screen.getByTestId("password-input"); | |||
fireEvent.change(password, { | |||
target: { | |||
value: "password", | |||
}, | |||
}); | |||
fireEvent.submit(signInForm); | |||
await waitFor(() => { | |||
expect(mockOnSubmit.mock.calls.length).toBe(1); | |||
expect(mockOnSubmit.mock.calls[0][0]).toEqual({ | |||
email: "contact@example.org", | |||
password: "password", | |||
}); | |||
}); | |||
}); | |||
it("Registerform on submit is not triggered if use doesn't accept Terms of service", async () => { | |||
const mockOnSubmit = jest.fn(); | |||
render(<RegisterForm onSubmit={mockOnSubmit} />); | |||
const signInForm = screen.getByTestId("register-form"); | |||
expect(signInForm).not.toBe(null); | |||
const email = screen.getByTestId("email-input"); | |||
fireEvent.change(email, { | |||
target: { | |||
value: "contact@example.org", | |||
}, | |||
}); | |||
const password = screen.getByTestId("password-input"); | |||
fireEvent.change(password, { | |||
target: { | |||
value: "password", | |||
}, | |||
}); | |||
const tou = screen.getByTestId("term-of-use-checkbox"); | |||
fireEvent.click(tou); | |||
fireEvent.submit(signInForm); | |||
await waitFor(() => { | |||
expect(mockOnSubmit.mock.calls.length).toBe(0); | |||
}); | |||
}); |
@@ -0,0 +1,70 @@ | |||
import React, { useState } from "react"; | |||
type Credentials = { | |||
email: string; | |||
password: string; | |||
}; | |||
type RegisterFormProps = { | |||
id?: string; | |||
onSubmit: (credentials: Credentials) => void; | |||
}; | |||
const RegisterForm = ({ | |||
id = "register-form", | |||
onSubmit, | |||
}: RegisterFormProps) => { | |||
const [email, setEmail] = useState<string>(""); | |||
const [password, setPassword] = useState<string>(""); | |||
const [termOfUse, setTermOfUse] = useState(true); | |||
return ( | |||
<form | |||
id={id} | |||
data-testid={id} | |||
onSubmit={(event) => { | |||
event.preventDefault(); | |||
const credential = { | |||
email, | |||
password, | |||
}; | |||
if (termOfUse) { | |||
onSubmit(credential); | |||
} | |||
}} | |||
> | |||
<label htmlFor="email-input">Email</label> | |||
<input | |||
type="email" | |||
id="email-input" | |||
data-testid="email-input" | |||
name="email" | |||
value={email} | |||
onChange={(event) => setEmail(event.target.value)} | |||
/> | |||
<label htmlFor="password-input">Password</label> | |||
<input | |||
type="password" | |||
id="password-input" | |||
data-testid="password-input" | |||
name="password" | |||
value={password} | |||
onChange={(event) => setPassword(event.target.value)} | |||
/> | |||
<input | |||
type="checkbox" | |||
id="term-of-use-checkbox" | |||
data-testid="term-of-use-checkbox" | |||
name="term-of-use" | |||
checked={termOfUse} | |||
onChange={(event) => setTermOfUse(event.target.checked)} | |||
/> | |||
<label htmlFor="term-of-use-checkbox">accept terms of use</label> | |||
<button type="submit" id="register-button" data-testid="register-button"> | |||
Create an account | |||
</button> | |||
</form> | |||
); | |||
}; | |||
export default RegisterForm; |
@@ -0,0 +1,20 @@ | |||
import React from "react"; | |||
import { Link } from "react-router-dom"; | |||
import SignInForm from "./SignInForm"; | |||
import { useTranslation } from "react-i18next"; | |||
const RegisterPage = () => { | |||
const { t } = useTranslation(); | |||
return ( | |||
<div> | |||
<SignInForm | |||
id="sign-in-form" | |||
onSubmit={(credential) => console.log(credential)} | |||
/> | |||
{t("auth.alreadyhaveanaccount")} | |||
<Link to="/signIn">sign in</Link> | |||
</div> | |||
); | |||
}; | |||
export default RegisterPage; |
@@ -0,0 +1,30 @@ | |||
import React from "react"; | |||
import { render, screen, fireEvent, waitFor } from "@testing-library/react"; | |||
import SignInForm from "./SignInForm"; | |||
it("SignInform on submit", async () => { | |||
const mockOnSubmit = jest.fn(); | |||
render(<SignInForm onSubmit={mockOnSubmit} />); | |||
const signInForm = screen.getByTestId("sign-in-form"); | |||
expect(signInForm).not.toBe(null); | |||
const email = screen.getByTestId("email-input"); | |||
fireEvent.change(email, { | |||
target: { | |||
value: "contact@example.org", | |||
}, | |||
}); | |||
const password = screen.getByTestId("password-input"); | |||
fireEvent.change(password, { | |||
target: { | |||
value: "password", | |||
}, | |||
}); | |||
fireEvent.submit(signInForm); | |||
await waitFor(() => { | |||
expect(mockOnSubmit.mock.calls.length).toBe(1); | |||
expect(mockOnSubmit.mock.calls[0][0]).toEqual({ | |||
email: "contact@example.org", | |||
password: "password", | |||
}); | |||
}); | |||
}); |
@@ -0,0 +1,54 @@ | |||
import React, { useState } from "react"; | |||
type Credentials = { | |||
email: string; | |||
password: string; | |||
}; | |||
type SignInFormProps = { | |||
id?: string; | |||
onSubmit: (credentials: Credentials) => void; | |||
}; | |||
const SignInForm = ({ id = "sign-in-form", onSubmit }: SignInFormProps) => { | |||
const [email, setEmail] = useState<string>(""); | |||
const [password, setPassword] = useState<string>(""); | |||
return ( | |||
<form | |||
id={id} | |||
data-testid={id} | |||
onSubmit={(event) => { | |||
event.preventDefault(); | |||
const credential = { | |||
email, | |||
password, | |||
}; | |||
onSubmit(credential); | |||
}} | |||
> | |||
<label htmlFor="email-input">Email</label> | |||
<input | |||
type="email" | |||
id="email-input" | |||
data-testid="email-input" | |||
name="email" | |||
value={email} | |||
onChange={(event) => setEmail(event.target.value)} | |||
/> | |||
<label htmlFor="password-input">Password</label> | |||
<input | |||
type="password" | |||
id="password-input" | |||
data-testid="password-input" | |||
name="password" | |||
value={password} | |||
onChange={(event) => setPassword(event.target.value)} | |||
/> | |||
<button type="submit" id="sign-in-button" data-testid="sign-in-button"> | |||
Sign In | |||
</button> | |||
</form> | |||
); | |||
}; | |||
export default SignInForm; |
@@ -0,0 +1,22 @@ | |||
import React from "react"; | |||
import { Link } from "react-router-dom"; | |||
import SignInForm from "./SignInForm"; | |||
import { useTranslation } from "react-i18next"; | |||
const SignInPage = () => { | |||
const { t } = useTranslation(); | |||
return ( | |||
<div> | |||
<SignInForm | |||
id="sign-in-form" | |||
onSubmit={(credential) => console.log(credential)} | |||
/> | |||
{t("auth.donthaveanaccount")} | |||
<Link data-testid="register-link" to="/register"> | |||
register | |||
</Link> | |||
</div> | |||
); | |||
}; | |||
export default SignInPage; |
@@ -0,0 +1,56 @@ | |||
import configureMockStore from "redux-mock-store"; | |||
import { mocked } from "ts-jest/utils"; | |||
import thunk from "redux-thunk"; | |||
import * as api from "../services/api"; | |||
import { | |||
AuthState, | |||
initialState, | |||
signIn, | |||
signInStart, | |||
signInSuccess, | |||
signInFailure, | |||
} from "./authSlice"; | |||
import { AppDispatch } from "../store"; | |||
jest.mock("../services/api"); | |||
const mockedApi = mocked(api, true); | |||
const mockedStore = configureMockStore<AuthState, AppDispatch>([thunk]); | |||
test("creates both signInStart and signInSuccess when signIn succeeds", async () => { | |||
const requestPayload = { | |||
email: "contact@example.org", | |||
password: "password", | |||
}; | |||
const responsePayload = { | |||
access: "access_token", | |||
refresh: "refresh_token", | |||
}; | |||
const store = mockedStore(initialState); | |||
mockedApi.signIn.mockResolvedValueOnce(responsePayload); | |||
await store.dispatch(signIn(requestPayload)); | |||
const expectedActions = [signInStart(), signInSuccess(responsePayload)]; | |||
expect(store.getActions()).toEqual(expectedActions); | |||
}); | |||
test("creates both signInStart and signInFailure when signIn fails", async () => { | |||
const requestPayload = { | |||
email: "contact@example.org", | |||
password: "wrong password", | |||
}; | |||
const responseError = new Error("Invalid credentials"); | |||
const store = mockedStore(initialState); | |||
mockedApi.signIn.mockRejectedValueOnce(responseError); | |||
await store.dispatch(signIn(requestPayload)); | |||
const expectedActions = [ | |||
signInStart(), | |||
signInFailure({ error: responseError }), | |||
]; | |||
expect(store.getActions()).toEqual(expectedActions); | |||
}); |
@@ -0,0 +1,63 @@ | |||
import reducer, { | |||
initialState, | |||
signInStart, | |||
signInSuccess, | |||
signInFailure, | |||
logout, | |||
selectIsLoading, | |||
selectError, | |||
selectIsAuthenticated, | |||
selectToken, | |||
} from "./authSlice"; | |||
import { PayloadAction } from "@reduxjs/toolkit"; | |||
import { RootState } from "../store"; | |||
test("should return the initial state", () => { | |||
const nextState = initialState; | |||
const result = reducer(undefined, {} as PayloadAction); | |||
expect(result).toEqual(nextState); | |||
}); | |||
test("should properly set loading and error state when signInStart", () => { | |||
const nextState = reducer(initialState, signInStart()); | |||
const rootState = { auth: nextState } as RootState; | |||
expect(selectIsAuthenticated(rootState)).toEqual(false); | |||
expect(selectIsLoading(rootState)).toEqual(true); | |||
expect(selectError(rootState)).toEqual(null); | |||
}); | |||
it("should properly set loading, error and user information when signInSuccess", () => { | |||
const payload = { access: "access_token" }; | |||
const nextState = reducer(initialState, signInSuccess(payload)); | |||
const rootState = { auth: nextState } as RootState; | |||
expect(selectIsAuthenticated(rootState)).toEqual(true); | |||
expect(selectToken(rootState)).toEqual(payload.access); | |||
expect(selectIsLoading(rootState)).toEqual(false); | |||
expect(selectError(rootState)).toEqual(null); | |||
}); | |||
it("should properly set loading, error and remove user information when signInFailure", () => { | |||
const error = new Error("Incorrect password"); | |||
const nextState = reducer( | |||
initialState, | |||
signInFailure({ error: error.message }) | |||
); | |||
const rootState = { auth: nextState } as RootState; | |||
expect(selectIsAuthenticated(rootState)).toEqual(false); | |||
expect(selectToken(rootState)).toEqual(null); | |||
expect(selectIsLoading(rootState)).toEqual(false); | |||
expect(selectError(rootState)).toEqual(error.message); | |||
}); | |||
it("should properly set loading, error and remove user information when token is null", () => { | |||
const payload = { access: "access_token" }; | |||
const nextState = reducer( | |||
reducer(initialState, signInSuccess(payload)), | |||
logout() | |||
); | |||
const rootState = { auth: nextState } as RootState; | |||
expect(selectIsAuthenticated(rootState)).toEqual(false); | |||
expect(selectToken(rootState)).toEqual(null); | |||
expect(selectIsLoading(rootState)).toEqual(false); | |||
expect(selectError(rootState)).toEqual(null); | |||
}); |
@@ -0,0 +1,72 @@ | |||
import { createSlice, createSelector, PayloadAction } from "@reduxjs/toolkit"; | |||
import * as api from "../services/api"; | |||
import { RootState, AppDispatch } from "../store"; | |||
export type AuthState = { | |||
isLoading: boolean; | |||
token: AccessToken | null; | |||
error: string | null; | |||
}; | |||
export const initialState: AuthState = { | |||
isLoading: false, | |||
token: null, | |||
error: null, | |||
}; | |||
const authSlice = createSlice({ | |||
name: "auth", | |||
initialState, | |||
reducers: { | |||
signInStart(state) { | |||
state.isLoading = true; | |||
state.error = null; | |||
}, | |||
signInSuccess(state, action: PayloadAction<SignInResponsePayload>) { | |||
const { access } = action.payload; | |||
state.token = access; | |||
state.isLoading = false; | |||
state.error = null; | |||
}, | |||
signInFailure(state, action) { | |||
const { error } = action.payload; | |||
state.isLoading = false; | |||
state.token = null; | |||
state.error = error; | |||
}, | |||
logout(state) { | |||
state.isLoading = false; | |||
state.token = null; | |||
state.error = null; | |||
}, | |||
}, | |||
}); | |||
export const { | |||
signInStart, | |||
signInSuccess, | |||
signInFailure, | |||
logout, | |||
} = authSlice.actions; | |||
export default authSlice.reducer; | |||
export const selectToken = (state: RootState) => state.auth.token; | |||
export const selectError = (state: RootState) => state.auth.error; | |||
export const selectIsLoading = (state: RootState) => state.auth.isLoading; | |||
export const selectIsAuthenticated = createSelector( | |||
[selectToken], | |||
(token) => token !== null | |||
); | |||
export const signIn = ({ email, password }: SignInRequestPayload) => async ( | |||
dispatch: AppDispatch | |||
) => { | |||
try { | |||
dispatch(signInStart()); | |||
const tokens = await api.signIn({ email, password }); | |||
dispatch(signInSuccess(tokens)); | |||
} catch (error) { | |||
dispatch(signInFailure({ error })); | |||
} | |||
}; |
@@ -0,0 +1,49 @@ | |||
import React from "react"; | |||
import { Link } from "react-router-dom"; | |||
import { useTranslation } from "react-i18next"; | |||
import { useSelector } from "react-redux"; | |||
import { selectIsAuthenticated } from "../auth/authSlice"; | |||
const Nav = () => { | |||
const { t } = useTranslation(); | |||
const isAuthenticated = useSelector(selectIsAuthenticated); | |||
if (isAuthenticated) { | |||
return ( | |||
<nav> | |||
<ul> | |||
<li> | |||
<Link to="/">LessPass</Link> | |||
</li> | |||
<li> | |||
<Link data-testid="password-link" to="/passwords">{t("nav.passwords")}</Link> | |||
</li> | |||
<li> | |||
<Link data-testid="settings-link" to="/settings"> | |||
{t("nav.settings")} | |||
</Link> | |||
</li> | |||
<li> | |||
<Link data-testid="my-account-link" to="/my_account">{t("nav.myaccount")}</Link> | |||
</li> | |||
</ul> | |||
</nav> | |||
); | |||
} | |||
return ( | |||
<nav> | |||
<ul> | |||
<li> | |||
<Link to="/">LessPass</Link> | |||
</li> | |||
<li> | |||
<Link data-testid="settings-link" to="/settings">{t("nav.settings")}</Link> | |||
</li> | |||
<li> | |||
<Link data-testid="sign-in-link" to="/signIn">{t("nav.signin")}</Link> | |||
</li> | |||
</ul> | |||
</nav> | |||
); | |||
}; | |||
export default Nav; |
@@ -0,0 +1,123 @@ | |||
type Serializable = object | string | boolean; | |||
type Store = { | |||
getItem(key: string): Serializable | null; | |||
setItem(key: string, value: Serializable): void; | |||
removeItem(key: string): void; | |||
}; | |||
type MasterPassword = string; | |||
type Settings = { | |||
saveMasterPassword: boolean; | |||
useMasterPasswordForAuth: boolean; | |||
language: string | null; | |||
}; | |||
type PasswordProfile = { | |||
site: string; | |||
login: string; | |||
lowercase: boolean; | |||
uppercase: boolean; | |||
digits: boolean; | |||
symbols: boolean; | |||
length: number; | |||
counter: number; | |||
}; | |||
type LegacyPasswordProfile = { | |||
id:string; | |||
site: string; | |||
login: string; | |||
lowercase: boolean; | |||
uppercase: boolean; | |||
numbers: boolean; | |||
symbols: boolean; | |||
length: number; | |||
counter: number; | |||
}; | |||
type FingerprintColor = | |||
| "#000000" | |||
| "#074750" | |||
| "#009191" | |||
| "#FF6CB6" | |||
| "#FFB5DA" | |||
| "#490092" | |||
| "#006CDB" | |||
| "#B66DFF" | |||
| "#6DB5FE" | |||
| "#B5DAFE" | |||
| "#920000" | |||
| "#924900" | |||
| "#DB6D00" | |||
| "#24FE23"; | |||
type FingerprintIcon = | |||
| "fa-hashtag" | |||
| "fa-heart" | |||
| "fa-hotel" | |||
| "fa-university" | |||
| "fa-plug" | |||
| "fa-ambulance" | |||
| "fa-bus" | |||
| "fa-car" | |||
| "fa-plane" | |||
| "fa-rocket" | |||
| "fa-ship" | |||
| "fa-subway" | |||
| "fa-truck" | |||
| "fa-jpy" | |||
| "fa-eur" | |||
| "fa-btc" | |||
| "fa-usd" | |||
| "fa-gbp" | |||
| "fa-archive" | |||
| "fa-area-chart" | |||
| "fa-bed" | |||
| "fa-beer" | |||
| "fa-bell" | |||
| "fa-binoculars" | |||
| "fa-birthday-cake" | |||
| "fa-bomb" | |||
| "fa-briefcase" | |||
| "fa-bug" | |||
| "fa-camera" | |||
| "fa-cart-plus" | |||
| "fa-certificate" | |||
| "fa-coffee" | |||
| "fa-cloud" | |||
| "fa-comment" | |||
| "fa-cube" | |||
| "fa-cutlery" | |||
| "fa-database" | |||
| "fa-diamond" | |||
| "fa-exclamation-circle" | |||
| "fa-eye" | |||
| "fa-flag" | |||
| "fa-flask" | |||
| "fa-futbol-o" | |||
| "fa-gamepad" | |||
| "fa-graduation-cap"; | |||
type Finger = { | |||
icon: FingerprintIcon; | |||
color: FingerprintColor; | |||
}; | |||
type Fingerprint = [Finger, Finger, Finger]; | |||
type AccessToken = string; | |||
type SignInRequestPayload = { | |||
email: string; | |||
password: string; | |||
}; | |||
type SignInResponsePayload = { | |||
access: AccessToken; | |||
}; | |||
type GetPasswordsResponsePayload = { | |||
results: LegacyPasswordProfile[]; | |||
}; |
@@ -0,0 +1,15 @@ | |||
{ | |||
"nav": { | |||
"settings": "settings", | |||
"passwords": "passwords", | |||
"signin": "sign in", | |||
"myaccount": "my account" | |||
}, | |||
"settings": { | |||
"selectlanguage": "select default language" | |||
}, | |||
"auth": { | |||
"donthaveanaccount": "don't have an account?", | |||
"alreadyhaveanaccount": "already have an account?" | |||
} | |||
} |
@@ -0,0 +1,15 @@ | |||
{ | |||
"nav": { | |||
"settings": "options", | |||
"passwords": "mots de passe", | |||
"signin": "se connecter", | |||
"myaccount": "mon compte" | |||
}, | |||
"settings": { | |||
"selectlanguage": "selectionnez la langue par défault" | |||
}, | |||
"auth": { | |||
"donthaveanaccount": "pas encore de compte?", | |||
"alreadyhaveanaccount": "vous avez déjà un compte?" | |||
} | |||
} |
@@ -0,0 +1,27 @@ | |||
import i18n from "i18next"; | |||
import { initReactI18next } from "react-i18next"; | |||
import resources from "./resources"; | |||
import { SETTINGS_KEY } from "../services/localStore"; | |||
import defaultSettings from "../settings/defaultSettings"; | |||
export function initI18n(store: Store) { | |||
const lang = window.navigator.languages | |||
? window.navigator.languages[0] | |||
: window.navigator.language; | |||
let shortLang = lang; | |||
if (shortLang.indexOf("-") !== -1) shortLang = shortLang.split("-")[0]; | |||
if (shortLang.indexOf("_") !== -1) shortLang = shortLang.split("_")[0]; | |||
const settings = (store.getItem(SETTINGS_KEY) as Settings) || defaultSettings; | |||
const { language } = settings; | |||
i18n.use(initReactI18next).init({ | |||
resources, | |||
lng: language || shortLang, | |||
fallbackLng: "en", | |||
interpolation: { | |||
escapeValue: false, | |||
}, | |||
}); | |||
return i18n; | |||
} |
@@ -0,0 +1,13 @@ | |||
import en_translation from "./en.json"; | |||
import fr_translation from "./fr.json"; | |||
const resources = { | |||
en: { | |||
translation: en_translation, | |||
}, | |||
fr: { | |||
translation: fr_translation, | |||
}, | |||
}; | |||
export default resources; |
@@ -0,0 +1,48 @@ | |||
import { getIconLookup } from "./icons"; | |||
test("getIconLookup", async () => { | |||
expect(getIconLookup("fa-flask")).toEqual({ | |||
prefix: "fas", | |||
iconName: "flask", | |||
}); | |||
expect(getIconLookup("fa-birthday-cake")).toEqual({ | |||
prefix: "fas", | |||
iconName: "birthday-cake", | |||
}); | |||
}); | |||
test("getIconLookup retro compatibility with fa4 icons", async () => { | |||
expect(getIconLookup("fa-jpy")).toEqual({ | |||
prefix: "fas", | |||
iconName: "yen-sign", | |||
}); | |||
expect(getIconLookup("fa-eur")).toEqual({ | |||
prefix: "fas", | |||
iconName: "euro-sign", | |||
}); | |||
expect(getIconLookup("fa-usd")).toEqual({ | |||
prefix: "fas", | |||
iconName: "dollar-sign", | |||
}); | |||
expect(getIconLookup("fa-gbp")).toEqual({ | |||
prefix: "fas", | |||
iconName: "pound-sign", | |||
}); | |||
expect(getIconLookup("fa-area-chart")).toEqual({ | |||
prefix: "fas", | |||
iconName: "chart-area", | |||
}); | |||
expect(getIconLookup("fa-cutlery")).toEqual({ | |||
prefix: "fas", | |||
iconName: "utensils", | |||
}); | |||
expect(getIconLookup("fa-diamond")).toEqual({ | |||
prefix: "fas", | |||
iconName: "gem", | |||
}); | |||
expect(getIconLookup("fa-futbol-o")).toEqual({ | |||
prefix: "fas", | |||
iconName: "futbol", | |||
}); | |||
expect(getIconLookup("fa-btc")).toEqual({ prefix: "fab", iconName: "btc" }); | |||
}); |
@@ -0,0 +1,129 @@ | |||
import { | |||
library, | |||
IconName, | |||
IconLookup, | |||
IconDefinition, | |||
findIconDefinition, | |||
} from "@fortawesome/fontawesome-svg-core"; | |||
import { faBtc } from "@fortawesome/free-brands-svg-icons"; | |||
import { | |||
faHashtag, | |||
faHeart, | |||
faHotel, | |||
faUniversity, | |||
faPlug, | |||
faAmbulance, | |||
faBus, | |||
faCar, | |||
faPlane, | |||
faRocket, | |||
faShip, | |||
faSubway, | |||
faTruck, | |||
faYenSign, | |||
faEuroSign, | |||
faDollarSign, | |||
faPoundSign, | |||
faArchive, | |||
faChartArea, | |||
faBed, | |||
faBeer, | |||
faBell, | |||
faBinoculars, | |||
faBirthdayCake, | |||
faBomb, | |||
faBriefcase, | |||
faBug, | |||
faCamera, | |||
faCartPlus, | |||
faCertificate, | |||
faCoffee, | |||
faCloud, | |||
faComment, | |||
faCube, | |||
faUtensils, | |||
faDatabase, | |||
faGem, | |||
faExclamationCircle, | |||
faEye, | |||
faFlag, | |||
faFlask, | |||
faFutbol, | |||
faGamepad, | |||
faGraduationCap, | |||
} from "@fortawesome/free-solid-svg-icons"; | |||
library.add( | |||
faHashtag, | |||
faHeart, | |||
faHotel, | |||
faUniversity, | |||
faPlug, | |||
faAmbulance, | |||
faBus, | |||
faCar, | |||
faPlane, | |||
faRocket, | |||
faShip, | |||
faSubway, | |||
faTruck, | |||
faYenSign, | |||
faEuroSign, | |||
faBtc, | |||
faDollarSign, | |||
faPoundSign, | |||
faArchive, | |||
faChartArea, | |||
faBed, | |||
faBeer, | |||
faBell, | |||
faBinoculars, | |||
faBirthdayCake, | |||
faBomb, | |||
faBriefcase, | |||
faBug, | |||
faCamera, | |||
faCartPlus, | |||
faCertificate, | |||
faCoffee, | |||
faCloud, | |||
faCoffee, | |||
faComment, | |||
faCube, | |||
faUtensils, | |||
faDatabase, | |||
faGem, | |||
faExclamationCircle, | |||
faEye, | |||
faFlag, | |||
faFlask, | |||
faFutbol, | |||
faGamepad, | |||
faGraduationCap | |||
); | |||
export function getIconLookup(old_name: FingerprintIcon): IconLookup { | |||
const incompatible_icon_names: Partial<Record< | |||
FingerprintIcon, | |||
IconLookup | |||
>> = { | |||
"fa-jpy": { prefix: "fas", iconName: "yen-sign" }, | |||
"fa-eur": { prefix: "fas", iconName: "euro-sign" }, | |||
"fa-usd": { prefix: "fas", iconName: "dollar-sign" }, | |||
"fa-gbp": { prefix: "fas", iconName: "pound-sign" }, | |||
"fa-btc": { prefix: "fab", iconName: "btc" }, | |||
"fa-area-chart": { prefix: "fas", iconName: "chart-area" }, | |||
"fa-cutlery": { prefix: "fas", iconName: "utensils" }, | |||
"fa-diamond": { prefix: "fas", iconName: "gem" }, | |||
"fa-futbol-o": { prefix: "fas", iconName: "futbol" }, | |||
}; | |||
if (Object.keys(incompatible_icon_names).includes(old_name)) { | |||
return incompatible_icon_names[old_name] as IconLookup; | |||
} | |||
const [, ...rest] = old_name.split("-"); | |||
return { prefix: "fas", iconName: rest.join("-") as IconName }; | |||
} | |||
export function getIcon(iconName: FingerprintIcon): IconDefinition { | |||
return findIconDefinition(getIconLookup(iconName)); | |||
} |
@@ -0,0 +1,27 @@ | |||
import React, { Suspense } from "react"; | |||
import ReactDOM from "react-dom"; | |||
import { Provider } from "react-redux"; | |||
//import "./index.css"; | |||
import App from "./App"; | |||
import { BrowserRouter } from "react-router-dom"; | |||
import { I18nextProvider } from "react-i18next"; | |||
import { initI18n } from "./i18n"; | |||
import reduxStore from "./store"; | |||
import initLocalStore from "./services/localStore"; | |||
const localStore = initLocalStore(window.localStorage); | |||
ReactDOM.render( | |||
<React.StrictMode> | |||
<Suspense fallback="..."> | |||
<Provider store={reduxStore}> | |||
<BrowserRouter> | |||
<I18nextProvider i18n={initI18n(localStore)}> | |||
<App store={localStore} /> | |||
</I18nextProvider> | |||
</BrowserRouter> | |||
</Provider> | |||
</Suspense> | |||
</React.StrictMode>, | |||
document.getElementById("root") | |||
); |
@@ -0,0 +1,78 @@ | |||
import React from "react"; | |||
import { render, screen, fireEvent, waitFor } from "@testing-library/react"; | |||
import PasswordGeneratorForm from "./PasswordGeneratorForm"; | |||
test("PasswordGeneratorForm", async () => { | |||
const onPasswordGenerated = jest.fn(); | |||
render( | |||
<PasswordGeneratorForm | |||
masterPassword="password" | |||
onPasswordGenerated={onPasswordGenerated} | |||
/> | |||
); | |||
const siteInput = screen.queryByTestId("site-input") as HTMLInputElement; | |||
expect(siteInput.type).toBe("text"); | |||
expect(siteInput.value).toBe(""); | |||
fireEvent.change(siteInput, { | |||
target: { value: "www.lesspass.com" }, | |||
}); | |||
const loginInput = screen.queryByTestId("login-input") as HTMLInputElement; | |||
expect(loginInput.type).toBe("text"); | |||
expect(loginInput.value).toBe(""); | |||
fireEvent.change(loginInput, { | |||
target: { value: "contact@lesspass.com" }, | |||
}); | |||
const uppercaseCheckbox = screen.queryByTestId( | |||
"uppercase-checkbox" | |||
) as HTMLInputElement; | |||
fireEvent.click(uppercaseCheckbox); | |||
const digitsCheckbox = screen.queryByTestId( | |||
"digits-checkbox" | |||
) as HTMLInputElement; | |||
fireEvent.click(digitsCheckbox); | |||
const symbolsCheckbox = screen.queryByTestId( | |||
"symbols-checkbox" | |||
) as HTMLInputElement; | |||
fireEvent.click(symbolsCheckbox); | |||
const lengthInput = screen.queryByTestId("length-input") as HTMLInputElement; | |||
expect(lengthInput.type).toBe("number"); | |||
expect(lengthInput.value).toBe("16"); | |||
fireEvent.change(lengthInput, { | |||
target: { value: "17" }, | |||
}); | |||
const counterInput = screen.queryByTestId( | |||
"counter-input" | |||
) as HTMLInputElement; | |||
expect(counterInput.type).toBe("number"); | |||
expect(counterInput.value).toBe("1"); | |||
fireEvent.change(counterInput, { | |||
target: { value: "2" }, | |||
}); | |||
const generatePasswordButton = screen.queryByTestId( | |||
"generate-password-button" | |||
) as HTMLInputElement; | |||
fireEvent.click(generatePasswordButton); | |||
await waitFor(() => | |||
expect(onPasswordGenerated).toHaveBeenCalledWith("sfexxbwympdakofqp") | |||
); | |||
}); | |||
test("site field is selected when we mount the password generator form", () => { | |||
render( | |||
<PasswordGeneratorForm | |||
masterPassword="password" | |||
onPasswordGenerated={jest.fn()} | |||
/> | |||
); | |||
const siteInput = screen.queryByTestId("site-input") as HTMLInputElement; | |||
expect(siteInput).toBe(document.activeElement); | |||
}); |
@@ -0,0 +1,141 @@ | |||
import React, { useState, useRef, useEffect } from "react"; | |||
const LessPass = require("lesspass"); | |||
const PasswordGeneratorForm = ({ | |||
masterPassword, | |||
onPasswordGenerated, | |||
}: { | |||
masterPassword: MasterPassword; | |||
onPasswordGenerated: (generatedPassword: string) => void; | |||
}) => { | |||
const [site, setSite] = useState(""); | |||
const [login, setLogin] = useState(""); | |||
const [lowercase, setLowercase] = useState(true); | |||
const [uppercase, setUppercase] = useState(true); | |||
const [digits, setDigits] = useState(true); | |||
const [symbols, setSymbols] = useState(true); | |||
const [length, setLength] = useState(16); | |||
const [counter, setCounter] = useState(1); | |||
const siteInputRef = useRef<HTMLInputElement>(null); | |||
useEffect(() => { | |||
siteInputRef.current?.focus(); | |||
}, []); | |||
return ( | |||
<form | |||
id="password-generator-form" | |||
data-testid="password-generator-form" | |||
onSubmit={(event) => { | |||
event.preventDefault(); | |||
const passwordProfile = { | |||
site, | |||
login, | |||
lowercase, | |||
uppercase, | |||
digits, | |||
symbols, | |||
length, | |||
counter, | |||
}; | |||
LessPass.generatePassword(passwordProfile, masterPassword).then( | |||
onPasswordGenerated | |||
); | |||
}} | |||
> | |||
<label htmlFor="site-input">Site</label> | |||
<input | |||
type="text" | |||
id="site-input" | |||
data-testid="site-input" | |||
name="site" | |||
ref={siteInputRef} | |||
value={site} | |||
onChange={(event) => setSite(event.target.value)} | |||
/> | |||
<label htmlFor="login-input">Last name</label> | |||
<input | |||
type="text" | |||
id="login-input" | |||
data-testid="login-input" | |||
name="login" | |||
value={login} | |||
onChange={(event) => setLogin(event.target.value)} | |||
/> | |||
<fieldset> | |||
<legend>Options</legend> | |||
<input | |||
type="checkbox" | |||
id="lowercase-checkbox" | |||
data-testid="lowercase-checkbox" | |||
name="lowercase" | |||
checked={lowercase} | |||
onChange={(event) => setLowercase(event.target.checked)} | |||
/> | |||
<label htmlFor="lowercase-checkbox">a-z</label> | |||
<input | |||
type="checkbox" | |||
id="uppercase-checkbox" | |||
data-testid="uppercase-checkbox" | |||
name="uppercase" | |||
checked={uppercase} | |||
onChange={(event) => setUppercase(event.target.checked)} | |||
/> | |||
<label htmlFor="uppercase-checkbox">A-Z</label> | |||
<input | |||
type="checkbox" | |||
id="digits-checkbox" | |||
data-testid="digits-checkbox" | |||
name="digits" | |||
checked={digits} | |||
onChange={(event) => setDigits(event.target.checked)} | |||
/> | |||
<label htmlFor="digits-checkbox">0-9</label> | |||
<input | |||
type="checkbox" | |||
id="symbols-checkbox" | |||
data-testid="symbols-checkbox" | |||
name="symbols" | |||
checked={symbols} | |||
onChange={(event) => setSymbols(event.target.checked)} | |||
/> | |||
<label htmlFor="symbols-checkbox">%!@</label> | |||
<label htmlFor="length-input">Length</label> | |||
<input | |||
type="number" | |||
id="length-input" | |||
data-testid="length-input" | |||
name="length" | |||
value={length} | |||
onChange={(event) => setLength(parseInt(event.target.value))} | |||
/> | |||
<label htmlFor="length-input">Counter</label> | |||
<input | |||
type="number" | |||
id="counter-input" | |||
data-testid="counter-input" | |||
name="counter" | |||
value={counter} | |||
onChange={(event) => setCounter(parseInt(event.target.value))} | |||
/> | |||
</fieldset> | |||
<button | |||
type="submit" | |||
id="generate-password-button" | |||
data-testid="generate-password-button" | |||
> | |||
Generate Password | |||
</button> | |||
</form> | |||
); | |||
}; | |||
export default PasswordGeneratorForm; |
@@ -0,0 +1,26 @@ | |||
import React from "react"; | |||
import PasswordGeneratorForm from "./PasswordGeneratorForm"; | |||
const PasswordGeneratorPage = ({ | |||
masterPassword, | |||
setMasterPassword, | |||
}: { | |||
setMasterPassword: (masterPassword: MasterPassword) => void; | |||
masterPassword: MasterPassword; | |||
}) => ( | |||
<> | |||
<PasswordGeneratorForm | |||
masterPassword={masterPassword} | |||
onPasswordGenerated={(generatedPassword) => alert(generatedPassword)} | |||
/> | |||
<button | |||
id="lock-button" | |||
data-testid="lock-button" | |||
onClick={() => setMasterPassword("")} | |||
> | |||
lock | |||
</button> | |||
</> | |||
); | |||
export default PasswordGeneratorPage; |
@@ -0,0 +1,5 @@ | |||
import React from "react"; | |||
const PasswordsPage = () => <div>PasswordsPage</div>; | |||
export default PasswordsPage; |
@@ -0,0 +1,63 @@ | |||
import configureMockStore from "redux-mock-store"; | |||
import { mocked } from "ts-jest/utils"; | |||
import thunk from "redux-thunk"; | |||
import * as api from "../services/api"; | |||
import { | |||
PasswordsState, | |||
initialState, | |||
getPasswords, | |||
getPasswordsStart, | |||
getPasswordsSuccess, | |||
getPasswordsFailure, | |||
} from "./passwordsSlice"; | |||
import { AppDispatch } from "../store"; | |||
jest.mock("../services/api"); | |||
const mockedApi = mocked(api, true); | |||
const mockedStore = configureMockStore<PasswordsState, AppDispatch>([thunk]); | |||
test("creates both getPasswordsStart and getPasswordsSuccess when getPasswords succeeds", async () => { | |||
const responsePayload = { | |||
results: [ | |||
{ | |||
id: "p1", | |||
site: "lesspass.com", | |||
login: "contact@lesspass.com", | |||
lowercase: true, | |||
uppercase: true, | |||
numbers: true, | |||
symbols: true, | |||
length: 16, | |||
counter: 1, | |||
}, | |||
], | |||
}; | |||
const store = mockedStore(initialState); | |||
mockedApi.getPasswords.mockResolvedValueOnce(responsePayload); | |||
await store.dispatch(getPasswords()); | |||
const expectedActions = [ | |||
getPasswordsStart(), | |||
getPasswordsSuccess(responsePayload), | |||
]; | |||
expect(store.getActions()).toEqual(expectedActions); | |||
}); | |||
test("creates both getPasswordsStart and getPasswordsFailure when getPasswords fails", async () => { | |||
const responseError = new Error("Invalid credentials"); | |||
const store = mockedStore(initialState); | |||
mockedApi.getPasswords.mockRejectedValueOnce(responseError); | |||
await store.dispatch(getPasswords()); | |||
const expectedActions = [ | |||
getPasswordsStart(), | |||
getPasswordsFailure({ error: responseError }), | |||
]; | |||
expect(store.getActions()).toEqual(expectedActions); | |||
}); |
@@ -0,0 +1,72 @@ | |||
import reducer, { | |||
initialState, | |||
getPasswordsStart, | |||
getPasswordsSuccess, | |||
getPasswordsFailure, | |||
selectIsLoading, | |||
selectError, | |||
selectPasswords, | |||
} from "./passwordsSlice"; | |||
import { PayloadAction } from "@reduxjs/toolkit"; | |||
import { RootState } from "../store"; | |||
test("should return the initial state", () => { | |||
const nextState = initialState; | |||
const result = reducer(undefined, {} as PayloadAction); | |||
expect(result).toEqual(nextState); | |||
}); | |||
test("should properly set loading and error state when getPasswordsStart", () => { | |||
const nextState = reducer(initialState, getPasswordsStart()); | |||
const rootState = { passwords: nextState } as RootState; | |||
expect(selectPasswords(rootState)).toEqual([]); | |||
expect(selectIsLoading(rootState)).toEqual(true); | |||
expect(selectError(rootState)).toEqual(null); | |||
}); | |||
it("should properly set loading, error and state when getPasswordsSuccess", () => { | |||
const payload = { | |||
results: [ | |||
{ | |||
id: "p1", | |||
site: "lesspass.com", | |||
login: "contact@lesspass.com", | |||
lowercase: true, | |||
uppercase: true, | |||
numbers: true, | |||
symbols: true, | |||
length: 16, | |||
counter: 1, | |||
}, | |||
], | |||
}; | |||
const nextState = reducer(initialState, getPasswordsSuccess(payload)); | |||
const rootState = { passwords: nextState } as RootState; | |||
expect(selectPasswords(rootState)).toEqual([ | |||
{ | |||
id: "p1", | |||
site: "lesspass.com", | |||
login: "contact@lesspass.com", | |||
lowercase: true, | |||
uppercase: true, | |||
digits: true, | |||
symbols: true, | |||
length: 16, | |||
counter: 1, | |||
}, | |||
]); | |||
expect(selectIsLoading(rootState)).toEqual(false); | |||
expect(selectError(rootState)).toEqual(null); | |||
}); | |||
it("should properly set loading, error and state getPasswordsFailure", () => { | |||
const error = new Error("Incorrect password"); | |||
const nextState = reducer( | |||
initialState, | |||
getPasswordsFailure({ error: error.message }) | |||
); | |||
const rootState = { passwords: nextState } as RootState; | |||
expect(selectPasswords(rootState)).toEqual([]); | |||
expect(selectIsLoading(rootState)).toEqual(false); | |||
expect(selectError(rootState)).toEqual(error.message); | |||
}); |
@@ -0,0 +1,70 @@ | |||
import { createSlice, PayloadAction } from "@reduxjs/toolkit"; | |||
import * as api from "../services/api"; | |||
import { RootState, AppDispatch } from "../store"; | |||
export type PasswordsState = { | |||
isLoading: boolean; | |||
results: PasswordProfile[]; | |||
error: string | null; | |||
}; | |||
export const initialState: PasswordsState = { | |||
isLoading: false, | |||
results: [], | |||
error: null, | |||
}; | |||
const passwordsSlice = createSlice({ | |||
name: "passwords", | |||
initialState, | |||
reducers: { | |||
getPasswordsStart(state) { | |||
state.isLoading = true; | |||
state.error = null; | |||
}, | |||
getPasswordsSuccess(state, action: PayloadAction<GetPasswordsResponsePayload>) { | |||
const { results } = action.payload; | |||
state.isLoading = false; | |||
state.results = results.map((r) => ({ | |||
id: r.id, | |||
site: r.site, | |||
login: r.login, | |||
lowercase: r.lowercase, | |||
uppercase: r.uppercase, | |||
digits: r.numbers, | |||
symbols: r.symbols, | |||
length: r.length, | |||
counter: r.counter, | |||
})); | |||
state.error = null; | |||
}, | |||
getPasswordsFailure(state, action) { | |||
const { error } = action.payload; | |||
state.isLoading = false; | |||
state.results = []; | |||
state.error = error; | |||
}, | |||
}, | |||
}); | |||
export const { | |||
getPasswordsStart, | |||
getPasswordsSuccess, | |||
getPasswordsFailure, | |||
} = passwordsSlice.actions; | |||
export default passwordsSlice.reducer; | |||
export const selectPasswords = (state: RootState) => state.passwords.results; | |||
export const selectError = (state: RootState) => state.passwords.error; | |||
export const selectIsLoading = (state: RootState) => state.passwords.isLoading; | |||
export const getPasswords = () => async (dispatch: AppDispatch) => { | |||
try { | |||
dispatch(getPasswordsStart()); | |||
const results = await api.getPasswords(); | |||
dispatch(getPasswordsSuccess(results)); | |||
} catch (error) { | |||
dispatch(getPasswordsFailure({ error })); | |||
} | |||
}; |
@@ -0,0 +1 @@ | |||
/// <reference types="react-scripts" /> |
@@ -0,0 +1,14 @@ | |||
import axios from "axios"; | |||
export function signIn(payload: SignInRequestPayload): Promise<SignInResponsePayload> { | |||
return axios | |||
.post("https://api.lesspass.com/api/auth/jwt/create/", payload) | |||
.then((response) => response.data); | |||
} | |||
export function getPasswords(): Promise<GetPasswordsResponsePayload> { | |||
return axios | |||
.get("https://api.lesspass.com/api/passwords/") | |||
.then((response) => response.data.results); | |||
} | |||
@@ -0,0 +1,46 @@ | |||
import { fakeLocalStore } from "./localStore"; | |||
test("can save an retrieve string", () => { | |||
const localStorage = fakeLocalStore(); | |||
localStorage.setItem("test", "string"); | |||
expect(localStorage.getItem("test")).toBe("string"); | |||
}); | |||
test("can save an delete string", () => { | |||
const localStorage = fakeLocalStore(); | |||
localStorage.setItem("test", "string"); | |||
expect(localStorage.getItem("test")).toBe("string"); | |||
localStorage.removeItem("test"); | |||
expect(localStorage.getItem("test")).toBeNull(); | |||
}); | |||
test("remove non existant string didnt raise an exception", () => { | |||
const localStorage = fakeLocalStore(); | |||
localStorage.removeItem("test"); | |||
}); | |||
test("can initialize local storage with string", () => { | |||
const localStorage = fakeLocalStore({ test: "string" }); | |||
expect(localStorage.getItem("test")).toBe("string"); | |||
}); | |||
test("can save an retrieve object", () => { | |||
const localStorage = fakeLocalStore(); | |||
localStorage.setItem("test", { hello: "world" }); | |||
expect(localStorage.getItem("test")).toEqual({ hello: "world" }); | |||
}); | |||
test("can initialize local storage with object and string", () => { | |||
const localStorage = fakeLocalStore({ | |||
a: "string", | |||
b: { hello: "world" }, | |||
}); | |||
expect(localStorage.getItem("a")).toBe("string"); | |||
expect(localStorage.getItem("b")).toEqual({ hello: "world" }); | |||
}); | |||
test("can save an retrieve boolean", () => { | |||
const localStorage = fakeLocalStore(); | |||
localStorage.setItem("test", false); | |||
expect(localStorage.getItem("test")).toBe(false); | |||
}); |
@@ -0,0 +1,65 @@ | |||
export const MASTER_PASSWORD_KEY = "masterPassword"; | |||
export const SETTINGS_KEY = "settings"; | |||
export const ACCESS_TOKEN = "access_token"; | |||
export const fakeLocalStore = (initialStore?: {}) => { | |||
class FakeLocalStore implements Store { | |||
store: { | |||
[key: string]: string | object | boolean; | |||
}; | |||
constructor(initialStore = {}) { | |||
this.store = initialStore; | |||
} | |||
getItem(key: string) { | |||
if (key in this.store) return this.store[key]; | |||
return null; | |||
} | |||
setItem(key: string, value: string | object | boolean) { | |||
this.store[key] = value; | |||
} | |||
removeItem(key: string) { | |||
delete this.store[key]; | |||
} | |||
} | |||
return new FakeLocalStore(initialStore); | |||
}; | |||
export default function initLocalStore(localStorage: Storage) { | |||
class LocalStore implements Store { | |||
localStorage: Storage; | |||
constructor(initialStore: Storage) { | |||
this.localStorage = initialStore; | |||
} | |||
getItem(key: string) { | |||
const value = this.localStorage.getItem(key); | |||
try { | |||
if (value === null) return value; | |||
return JSON.parse(value); | |||
} catch (error) { | |||
return value; | |||
} | |||
} | |||
setItem(key: string, value: string | object | boolean) { | |||
if (typeof value === "string") { | |||
return this.localStorage.setItem(key, value); | |||
} | |||
if (typeof value === "object" || typeof value === "boolean") { | |||
return this.localStorage.setItem(key, JSON.stringify(value)); | |||
} | |||
} | |||
removeItem(key: string) { | |||
return this.localStorage.removeItem(key); | |||
} | |||
} | |||
return new LocalStore(localStorage); | |||
} |
@@ -0,0 +1,94 @@ | |||
import React from "react"; | |||
import { render, screen, waitFor, fireEvent } from "@testing-library/react"; | |||
import { InitApp } from "../App.test"; | |||
import { createMemoryHistory } from "history"; | |||
import { fakeLocalStore } from "../services/localStore"; | |||
test("test settings page when local storage is empty", async () => { | |||
const history = createMemoryHistory(); | |||
render( | |||
<InitApp | |||
history={history} | |||
store={fakeLocalStore({ masterPassword: "password" })} | |||
/> | |||
); | |||
fireEvent.click(screen.queryByTestId("settings-link") as HTMLLinkElement); | |||
expect(history.location.pathname).toBe("/settings"); | |||
const useMasterPasswordForAuth = screen.queryByTestId( | |||
"use-master-password-for-auth-checkbox" | |||
) as HTMLInputElement; | |||
await waitFor(() => { | |||
expect(useMasterPasswordForAuth).toBeInTheDocument(); | |||
expect(useMasterPasswordForAuth.type).toBe("checkbox"); | |||
expect(useMasterPasswordForAuth.checked).toBe(true); | |||
}); | |||
const frRadioButton = screen.queryByTestId( | |||
"fr-radio-button" | |||
) as HTMLInputElement; | |||
const enRadioButton = screen.queryByTestId( | |||
"en-radio-button" | |||
) as HTMLInputElement; | |||
await waitFor(() => { | |||
expect(enRadioButton).toBeInTheDocument(); | |||
expect(enRadioButton.type).toBe("radio"); | |||
expect(enRadioButton.checked).toBe(true); | |||
expect(frRadioButton).toBeInTheDocument(); | |||
expect(frRadioButton.type).toBe("radio"); | |||
expect(frRadioButton.checked).toBe(false); | |||
}); | |||
}); | |||
test("test settings page when local storage has saved info", async () => { | |||
const history = createMemoryHistory(); | |||
render( | |||
<InitApp | |||
history={history} | |||
store={fakeLocalStore({ | |||
masterPassword: "password", | |||
settings: { useMasterPasswordForAuth: false, language: "fr" }, | |||
})} | |||
/> | |||
); | |||
fireEvent.click(screen.queryByTestId("settings-link") as HTMLLinkElement); | |||
expect(history.location.pathname).toBe("/settings"); | |||
const useMasterPasswordForAuth = screen.queryByTestId( | |||
"use-master-password-for-auth-checkbox" | |||
) as HTMLInputElement; | |||
await waitFor(() => { | |||
expect(useMasterPasswordForAuth).toBeInTheDocument(); | |||
expect(useMasterPasswordForAuth.type).toBe("checkbox"); | |||
expect(useMasterPasswordForAuth.checked).toBe(false); | |||
}); | |||
const frRadioButton = screen.queryByTestId( | |||
"fr-radio-button" | |||
) as HTMLInputElement; | |||
const enRadioButton = screen.queryByTestId( | |||
"en-radio-button" | |||
) as HTMLInputElement; | |||
await waitFor(() => { | |||
expect(enRadioButton.checked).toBe(false); | |||
expect(frRadioButton.checked).toBe(true); | |||
}); | |||
}); | |||
test("click on option save them in local store", async () => { | |||
const store = fakeLocalStore({ masterPassword: "password" }); | |||
render(<InitApp store={store} />); | |||
fireEvent.click(screen.queryByTestId("settings-link") as HTMLLinkElement); | |||
const frRadioButton = screen.queryByTestId( | |||
"fr-radio-button" | |||
) as HTMLInputElement; | |||
fireEvent.click(frRadioButton); | |||
await waitFor(() => { | |||
expect(store.getItem("settings")).toMatchObject({ | |||
useMasterPasswordForAuth: true, | |||
language: "fr", | |||
}); | |||
}); | |||
}); |
@@ -0,0 +1,71 @@ | |||
import React from "react"; | |||
import { useTranslation } from "react-i18next"; | |||
type SettingsPageProps = { | |||
settings: Settings; | |||
setSettings: (settings: Settings) => void; | |||
}; | |||
const SettingsPage = ({ settings, setSettings }: SettingsPageProps) => { | |||
const { t, i18n } = useTranslation(); | |||
const changeLanguage = (lng: string) => { | |||
i18n.changeLanguage(lng); | |||
}; | |||
const setLanguage = (event: React.ChangeEvent<HTMLInputElement>) => { | |||
const language = event.target.value; | |||
changeLanguage(language); | |||
setSettings({ | |||
...settings, | |||
language, | |||
}); | |||
}; | |||
return ( | |||
<div> | |||
<div> | |||
<input | |||
type="checkbox" | |||
id="use-master-password-for-auth-checkbox" | |||
data-testid="use-master-password-for-auth-checkbox" | |||
name="useMasterPasswordForAuth" | |||
checked={settings.useMasterPasswordForAuth} | |||
onChange={(event) => | |||
setSettings({ | |||
...settings, | |||
useMasterPasswordForAuth: event.target.checked, | |||
}) | |||
} | |||
/> | |||
<label htmlFor="use-master-password-for-auth-checkbox"> | |||
{t("settings.usemymasterpassword")} | |||
</label> | |||
<small>{t("settings.usemymasterpassworddescription")}</small> | |||
</div> | |||
<div> | |||
<input | |||
type="radio" | |||
id="en-radio-button" | |||
data-testid="en-radio-button" | |||
name="language" | |||
value="en" | |||
checked={i18n.language === "en"} | |||
onChange={setLanguage} | |||
/> | |||
<label htmlFor="en-radio-button">en</label> | |||
<input | |||
type="radio" | |||
id="fr-radio-button" | |||
data-testid="fr-radio-button" | |||
name="language" | |||
value="fr" | |||
checked={i18n.language === "fr"} | |||
onChange={setLanguage} | |||
/> | |||
<label htmlFor="fr-radio-button">fr</label> | |||
<div>{t("settings.selectlanguage")}</div> | |||
</div> | |||
</div> | |||
); | |||
}; | |||
export default SettingsPage; |
@@ -0,0 +1,7 @@ | |||
const defaultSettings: Settings = { | |||
saveMasterPassword: true, | |||
useMasterPasswordForAuth: true, | |||
language: null, | |||
}; | |||
export default defaultSettings; |
@@ -0,0 +1,5 @@ | |||
// jest-dom adds custom jest matchers for asserting on DOM nodes. | |||
// allows you to do things like: | |||
// expect(element).toHaveTextContent(/react/i) | |||
// learn more: https://github.com/testing-library/jest-dom | |||
import '@testing-library/jest-dom/extend-expect'; |
@@ -0,0 +1,15 @@ | |||
import { configureStore, combineReducers } from "@reduxjs/toolkit"; | |||
import auth from "./auth/authSlice"; | |||
import passwords from "./passwords/passwordsSlice"; | |||
const rootReducer = combineReducers({ auth, passwords }); | |||
const store = configureStore({ | |||
reducer: rootReducer, | |||
}); | |||
export type RootState = ReturnType<typeof store.getState> | |||
export type AppDispatch = typeof store.dispatch; | |||
export default store; |
@@ -0,0 +1,8 @@ | |||
/* purgecss start ignore */ | |||
@import "tailwindcss/base"; | |||
/* @import "./base.css"; */ | |||
@import "tailwindcss/components"; | |||
/* @import "./components.css"; */ | |||
/* purgecss end ignore */ | |||
@import "tailwindcss/utilities"; |
@@ -0,0 +1,104 @@ | |||
import React from "react"; | |||
import { | |||
render, | |||
screen, | |||
waitFor, | |||
fireEvent, | |||
act, | |||
} from "@testing-library/react"; | |||
import MasterPassword from "./MasterPassword"; | |||
const createFingerprintPromise = Promise.resolve([ | |||
{ | |||
color: "#FFB5DA", | |||
icon: "fa-flask", | |||
}, | |||
{ | |||
color: "#009191", | |||
icon: "fa-archive", | |||
}, | |||
{ | |||
color: "#B5DAFE", | |||
icon: "fa-beer", | |||
}, | |||
] as Fingerprint); | |||
beforeAll(() => { | |||
jest.useFakeTimers(); | |||
}); | |||
afterAll(() => { | |||
jest.useRealTimers(); | |||
}); | |||
test("MasterPassword", async () => { | |||
render( | |||
<MasterPassword | |||
value="password" | |||
createFingerprint={jest.fn(() => createFingerprintPromise)} | |||
onChange={jest.fn()} | |||
/> | |||
); | |||
jest.advanceTimersByTime(500); | |||
const masterPasswordInput = screen.queryByTestId( | |||
"master-password-input" | |||
) as HTMLInputElement; | |||
expect(masterPasswordInput).toBeInTheDocument(); | |||
expect(masterPasswordInput.type).toBe("password"); | |||
expect(masterPasswordInput.value).toBe("password"); | |||
expect(masterPasswordInput).toBe(document.activeElement); | |||
await waitFor(() => { | |||
expect(screen.queryByTitle("icon-fa-flask")).toBeInTheDocument(); | |||
expect(screen.queryByTitle("icon-fa-archive")).toBeInTheDocument(); | |||
expect(screen.queryByTitle("icon-fa-beer")).toBeInTheDocument(); | |||
}); | |||
}); | |||
test("MasterPassword createFingerprint has been called after debounce", async () => { | |||
const createFingerprint = jest.fn(() => createFingerprintPromise); | |||
render( | |||
<MasterPassword | |||
value="password" | |||
createFingerprint={createFingerprint} | |||
onChange={jest.fn()} | |||
/> | |||
); | |||
expect(createFingerprint).toHaveBeenCalledTimes(1); | |||
expect(createFingerprint).not.toHaveBeenCalledWith("password"); | |||
jest.advanceTimersByTime(500); | |||
expect(createFingerprint).toHaveBeenCalledWith("password"); | |||
await waitFor(() => { | |||
expect(screen.queryByTestId("fingerprint")).toBeInTheDocument(); | |||
expect(screen.queryByTitle("icon-fa-flask")).toBeInTheDocument(); | |||
expect(screen.queryByTitle("icon-fa-archive")).toBeInTheDocument(); | |||
expect(screen.queryByTitle("icon-fa-beer")).toBeInTheDocument(); | |||
}); | |||
}); | |||
test("Remove fingerprint if MasterPassword is cleared", async () => { | |||
render( | |||
<MasterPassword | |||
value="" | |||
createFingerprint={jest.fn(() => createFingerprintPromise)} | |||
onChange={jest.fn()} | |||
/> | |||
); | |||
const masterPasswordInput = screen.queryByTestId( | |||
"master-password-input" | |||
) as HTMLInputElement; | |||
fireEvent.change(masterPasswordInput, { | |||
target: { value: "p" }, | |||
}); | |||
await waitFor(() => { | |||
expect(screen.queryByTestId("fingerprint")).toBeInTheDocument(); | |||
}); | |||
fireEvent.change(masterPasswordInput, { | |||
target: { value: "" }, | |||
}); | |||
act(() => { | |||
jest.runAllTimers(); | |||
}); | |||
await waitFor(() => { | |||
expect(screen.queryByTestId("fingerprint")).toBeNull(); | |||
}); | |||
}); |
@@ -0,0 +1,72 @@ | |||
import React, { useEffect, useRef, useState } from "react"; | |||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; | |||
import { getIcon } from "../icons"; | |||
type MasterPasswordProps = { | |||
value: string; | |||
createFingerprint: (masterPassword: MasterPassword) => Promise<Fingerprint>; | |||
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void; | |||
}; | |||
const MasterPassword = ({ | |||
value, | |||
createFingerprint, | |||
onChange, | |||
}: MasterPasswordProps) => { | |||
const [fingerprint, setFingerprint] = useState<Fingerprint | null>(null); | |||
const masterPasswordInputRef = useRef<HTMLInputElement>(null); | |||
useEffect(() => { | |||
masterPasswordInputRef.current?.focus(); | |||
}, []); | |||
useEffect(() => { | |||
let isSubscribed = true; | |||
const fakeValue = Math.random().toString(36).substring(7); | |||
createFingerprint(fakeValue).then((fakedFingerprint: Fingerprint) => { | |||
if (isSubscribed) { | |||
setFingerprint(fakedFingerprint); | |||
} | |||
}); | |||
const setFingerprintTimer = setTimeout(() => { | |||
if (value) { | |||
createFingerprint(value).then(setFingerprint); | |||
} else { | |||
setFingerprint(null); | |||
} | |||
}, 500); | |||
return () => { | |||
isSubscribed = false; | |||
clearTimeout(setFingerprintTimer); | |||
}; | |||
}, [createFingerprint, value]); | |||
return ( | |||
<div> | |||
<input | |||
id="master-password-input" | |||
data-testid="master-password-input" | |||
name="masterPassword" | |||
type="password" | |||
ref={masterPasswordInputRef} | |||
value={value} | |||
onChange={onChange} | |||
/> | |||
{fingerprint && ( | |||
<div id="fingerprint" data-testid="fingerprint"> | |||
<FontAwesomeIcon | |||
title={`icon-${fingerprint[0].icon}`} | |||
icon={getIcon(fingerprint[0].icon)} | |||
/> | |||
<FontAwesomeIcon | |||
title={`icon-${fingerprint[1].icon}`} | |||
icon={getIcon(fingerprint[1].icon)} | |||
/> | |||
<FontAwesomeIcon | |||
title={`icon-${fingerprint[2].icon}`} | |||
icon={getIcon(fingerprint[2].icon)} | |||
/> | |||
</div> | |||
)} | |||
</div> | |||
); | |||
}; | |||
export default MasterPassword; |
@@ -0,0 +1,51 @@ | |||
import React, { useEffect, useRef, useState } from "react"; | |||
import MasterPassword from "./MasterPassword"; | |||
import { createFingerprint } from "lesspass"; | |||
export const UnlockPage = ({ | |||
settings, | |||
unlock, | |||
}: { | |||
settings: Settings; | |||
unlock: (masterPassword: MasterPassword, saveMasterPassword:boolean) => void; | |||
}) => { | |||
const [masterPassword, setMasterPassword] = useState<MasterPassword>(""); | |||
const [saveMasterPassword, setSaveMasterPassword] = useState(settings.saveMasterPassword); | |||
const masterPasswordInputRef = useRef<HTMLInputElement>(null); | |||
useEffect(() => { | |||
masterPasswordInputRef.current?.focus(); | |||
}, []); | |||
return ( | |||
<form | |||
onSubmit={(event) => { | |||
event.preventDefault(); | |||
unlock(masterPassword, saveMasterPassword); | |||
}} | |||
> | |||
<MasterPassword | |||
value={masterPassword} | |||
createFingerprint={createFingerprint} | |||
onChange={(event) => { | |||
event.persist(); | |||
setMasterPassword(event.target.value); | |||
}} | |||
/> | |||
<input | |||
id="save-master-password-checkbox" | |||
data-testid="save-master-password-checkbox" | |||
name="saveMasterPassword" | |||
type="checkbox" | |||
checked={saveMasterPassword} | |||
onChange={(event) => { | |||
setSaveMasterPassword(event.target.checked); | |||
}} | |||
/> | |||
keep master password locally <a href="#">understand the risk</a> | |||
<button id="unlock" data-testid="unlock"> | |||
unlock | |||
</button> | |||
</form> | |||
); | |||
}; | |||
export default UnlockPage; |
@@ -0,0 +1 @@ | |||
declare module "lesspass"; |
@@ -0,0 +1,12 @@ | |||
module.exports = { | |||
future: { | |||
removeDeprecatedGapUtilities: true, | |||
purgeLayersByDefault: true, | |||
}, | |||
purge: false, | |||
theme: { | |||
extend: {}, | |||
}, | |||
variants: {}, | |||
plugins: [], | |||
}; |
@@ -0,0 +1,25 @@ | |||
{ | |||
"compilerOptions": { | |||
"target": "es5", | |||
"lib": [ | |||
"dom", | |||
"dom.iterable", | |||
"esnext" | |||
], | |||
"allowJs": true, | |||
"skipLibCheck": true, | |||
"esModuleInterop": true, | |||
"allowSyntheticDefaultImports": true, | |||
"strict": true, | |||
"forceConsistentCasingInFileNames": true, | |||
"module": "esnext", | |||
"moduleResolution": "node", | |||
"resolveJsonModule": true, | |||
"isolatedModules": true, | |||
"noEmit": true, | |||
"jsx": "react" | |||
}, | |||
"include": [ | |||
"src" | |||
] | |||
} |