Browse Source

reveal sandbox lesspass in react

pull/584/head
Guillaume Vincent 3 years ago
parent
commit
6a8b0c1cd9
54 changed files with 14630 additions and 2 deletions
  1. +0
    -2
      sandbox/.gitignore
  2. +24
    -0
      sandbox/lesspass-web-component/.gitignore
  3. +76
    -0
      sandbox/lesspass-web-component/package.json
  4. +25
    -0
      sandbox/lesspass-web-component/postcss.config.js
  5. BIN
      sandbox/lesspass-web-component/public/favicon.ico
  6. +40
    -0
      sandbox/lesspass-web-component/public/index.html
  7. BIN
      sandbox/lesspass-web-component/public/logo192.png
  8. BIN
      sandbox/lesspass-web-component/public/logo512.png
  9. +25
    -0
      sandbox/lesspass-web-component/public/manifest.json
  10. +3
    -0
      sandbox/lesspass-web-component/public/robots.txt
  11. +258
    -0
      sandbox/lesspass-web-component/src/App.test.tsx
  12. +120
    -0
      sandbox/lesspass-web-component/src/App.tsx
  13. +55
    -0
      sandbox/lesspass-web-component/src/auth/RegisterForm.test.tsx
  14. +70
    -0
      sandbox/lesspass-web-component/src/auth/RegisterForm.tsx
  15. +20
    -0
      sandbox/lesspass-web-component/src/auth/RegisterPage.tsx
  16. +30
    -0
      sandbox/lesspass-web-component/src/auth/SignInForm.test.tsx
  17. +54
    -0
      sandbox/lesspass-web-component/src/auth/SignInForm.tsx
  18. +22
    -0
      sandbox/lesspass-web-component/src/auth/SignInPage.tsx
  19. +56
    -0
      sandbox/lesspass-web-component/src/auth/authApi.test.ts
  20. +63
    -0
      sandbox/lesspass-web-component/src/auth/authSlice.test.ts
  21. +72
    -0
      sandbox/lesspass-web-component/src/auth/authSlice.ts
  22. +49
    -0
      sandbox/lesspass-web-component/src/components/Nav.tsx
  23. +123
    -0
      sandbox/lesspass-web-component/src/global.d.ts
  24. +15
    -0
      sandbox/lesspass-web-component/src/i18n/en.json
  25. +15
    -0
      sandbox/lesspass-web-component/src/i18n/fr.json
  26. +27
    -0
      sandbox/lesspass-web-component/src/i18n/index.ts
  27. +13
    -0
      sandbox/lesspass-web-component/src/i18n/resources.ts
  28. +48
    -0
      sandbox/lesspass-web-component/src/icon.test.ts
  29. +129
    -0
      sandbox/lesspass-web-component/src/icons.ts
  30. +27
    -0
      sandbox/lesspass-web-component/src/index.tsx
  31. +78
    -0
      sandbox/lesspass-web-component/src/passwordGenerator/PasswordGeneratorForm.test.tsx
  32. +141
    -0
      sandbox/lesspass-web-component/src/passwordGenerator/PasswordGeneratorForm.tsx
  33. +26
    -0
      sandbox/lesspass-web-component/src/passwordGenerator/PasswordGeneratorPage.tsx
  34. +5
    -0
      sandbox/lesspass-web-component/src/passwords/PasswordsPage.tsx
  35. +63
    -0
      sandbox/lesspass-web-component/src/passwords/passwordsApi.test.ts
  36. +72
    -0
      sandbox/lesspass-web-component/src/passwords/passwordsSlice.test.ts
  37. +70
    -0
      sandbox/lesspass-web-component/src/passwords/passwordsSlice.ts
  38. +1
    -0
      sandbox/lesspass-web-component/src/react-app-env.d.ts
  39. +14
    -0
      sandbox/lesspass-web-component/src/services/api.ts
  40. +46
    -0
      sandbox/lesspass-web-component/src/services/localStore.test.ts
  41. +65
    -0
      sandbox/lesspass-web-component/src/services/localStore.ts
  42. +94
    -0
      sandbox/lesspass-web-component/src/settings/SettingsPage.test.tsx
  43. +71
    -0
      sandbox/lesspass-web-component/src/settings/SettingsPage.tsx
  44. +7
    -0
      sandbox/lesspass-web-component/src/settings/defaultSettings.ts
  45. +5
    -0
      sandbox/lesspass-web-component/src/setupTests.ts
  46. +15
    -0
      sandbox/lesspass-web-component/src/store.ts
  47. +8
    -0
      sandbox/lesspass-web-component/src/styles/tailwind.css
  48. +104
    -0
      sandbox/lesspass-web-component/src/unlock/MasterPassword.test.tsx
  49. +72
    -0
      sandbox/lesspass-web-component/src/unlock/MasterPassword.tsx
  50. +51
    -0
      sandbox/lesspass-web-component/src/unlock/UnlockPage.tsx
  51. +1
    -0
      sandbox/lesspass-web-component/src/vendor.d.ts
  52. +12
    -0
      sandbox/lesspass-web-component/tailwind.config.js
  53. +25
    -0
      sandbox/lesspass-web-component/tsconfig.json
  54. +12125
    -0
      sandbox/lesspass-web-component/yarn.lock

+ 0
- 2
sandbox/.gitignore View File

@@ -1,2 +0,0 @@
lesspass-site/
lesspass-web-component/

+ 24
- 0
sandbox/lesspass-web-component/.gitignore View File

@@ -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*

+ 76
- 0
sandbox/lesspass-web-component/package.json View File

@@ -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"
]
}
}

+ 25
- 0
sandbox/lesspass-web-component/postcss.config.js View File

@@ -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 })]
: []),
],
};

BIN
sandbox/lesspass-web-component/public/favicon.ico View File

Before After

+ 40
- 0
sandbox/lesspass-web-component/public/index.html View File

@@ -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>

BIN
sandbox/lesspass-web-component/public/logo192.png View File

Before After
Width: 600  |  Height: 600  |  Size: 16 KiB

BIN
sandbox/lesspass-web-component/public/logo512.png View File

Before After
Width: 1600  |  Height: 1600  |  Size: 49 KiB

+ 25
- 0
sandbox/lesspass-web-component/public/manifest.json View File

@@ -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"
}

+ 3
- 0
sandbox/lesspass-web-component/public/robots.txt View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow: /

+ 258
- 0
sandbox/lesspass-web-component/src/App.test.tsx View File

@@ -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");
});
});

+ 120
- 0
sandbox/lesspass-web-component/src/App.tsx View File

@@ -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;

+ 55
- 0
sandbox/lesspass-web-component/src/auth/RegisterForm.test.tsx View File

@@ -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);
});
});

+ 70
- 0
sandbox/lesspass-web-component/src/auth/RegisterForm.tsx View File

@@ -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;

+ 20
- 0
sandbox/lesspass-web-component/src/auth/RegisterPage.tsx View File

@@ -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;

+ 30
- 0
sandbox/lesspass-web-component/src/auth/SignInForm.test.tsx View File

@@ -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",
});
});
});

+ 54
- 0
sandbox/lesspass-web-component/src/auth/SignInForm.tsx View File

@@ -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;

+ 22
- 0
sandbox/lesspass-web-component/src/auth/SignInPage.tsx View File

@@ -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;

+ 56
- 0
sandbox/lesspass-web-component/src/auth/authApi.test.ts View File

@@ -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);
});

+ 63
- 0
sandbox/lesspass-web-component/src/auth/authSlice.test.ts View File

@@ -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);
});

+ 72
- 0
sandbox/lesspass-web-component/src/auth/authSlice.ts View File

@@ -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 }));
}
};

+ 49
- 0
sandbox/lesspass-web-component/src/components/Nav.tsx View File

@@ -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;

+ 123
- 0
sandbox/lesspass-web-component/src/global.d.ts View File

@@ -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[];
};

+ 15
- 0
sandbox/lesspass-web-component/src/i18n/en.json View File

@@ -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?"
}
}

+ 15
- 0
sandbox/lesspass-web-component/src/i18n/fr.json View File

@@ -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?"
}
}

+ 27
- 0
sandbox/lesspass-web-component/src/i18n/index.ts View File

@@ -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;
}

+ 13
- 0
sandbox/lesspass-web-component/src/i18n/resources.ts View File

@@ -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;

+ 48
- 0
sandbox/lesspass-web-component/src/icon.test.ts View File

@@ -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" });
});

+ 129
- 0
sandbox/lesspass-web-component/src/icons.ts View File

@@ -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));
}

+ 27
- 0
sandbox/lesspass-web-component/src/index.tsx View File

@@ -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")
);

+ 78
- 0
sandbox/lesspass-web-component/src/passwordGenerator/PasswordGeneratorForm.test.tsx View File

@@ -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);
});

+ 141
- 0
sandbox/lesspass-web-component/src/passwordGenerator/PasswordGeneratorForm.tsx View File

@@ -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;

+ 26
- 0
sandbox/lesspass-web-component/src/passwordGenerator/PasswordGeneratorPage.tsx View File

@@ -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;

+ 5
- 0
sandbox/lesspass-web-component/src/passwords/PasswordsPage.tsx View File

@@ -0,0 +1,5 @@
import React from "react";

const PasswordsPage = () => <div>PasswordsPage</div>;

export default PasswordsPage;

+ 63
- 0
sandbox/lesspass-web-component/src/passwords/passwordsApi.test.ts View File

@@ -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);
});

+ 72
- 0
sandbox/lesspass-web-component/src/passwords/passwordsSlice.test.ts View File

@@ -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);
});

+ 70
- 0
sandbox/lesspass-web-component/src/passwords/passwordsSlice.ts View File

@@ -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 }));
}
};

+ 1
- 0
sandbox/lesspass-web-component/src/react-app-env.d.ts View File

@@ -0,0 +1 @@
/// <reference types="react-scripts" />

+ 14
- 0
sandbox/lesspass-web-component/src/services/api.ts View File

@@ -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);
}


+ 46
- 0
sandbox/lesspass-web-component/src/services/localStore.test.ts View File

@@ -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);
});

+ 65
- 0
sandbox/lesspass-web-component/src/services/localStore.ts View File

@@ -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);
}

+ 94
- 0
sandbox/lesspass-web-component/src/settings/SettingsPage.test.tsx View File

@@ -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",
});
});
});

+ 71
- 0
sandbox/lesspass-web-component/src/settings/SettingsPage.tsx View File

@@ -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;

+ 7
- 0
sandbox/lesspass-web-component/src/settings/defaultSettings.ts View File

@@ -0,0 +1,7 @@
const defaultSettings: Settings = {
saveMasterPassword: true,
useMasterPasswordForAuth: true,
language: null,
};

export default defaultSettings;

+ 5
- 0
sandbox/lesspass-web-component/src/setupTests.ts View File

@@ -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';

+ 15
- 0
sandbox/lesspass-web-component/src/store.ts View File

@@ -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;

+ 8
- 0
sandbox/lesspass-web-component/src/styles/tailwind.css View File

@@ -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";

+ 104
- 0
sandbox/lesspass-web-component/src/unlock/MasterPassword.test.tsx View File

@@ -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();
});
});

+ 72
- 0
sandbox/lesspass-web-component/src/unlock/MasterPassword.tsx View File

@@ -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;

+ 51
- 0
sandbox/lesspass-web-component/src/unlock/UnlockPage.tsx View File

@@ -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;

+ 1
- 0
sandbox/lesspass-web-component/src/vendor.d.ts View File

@@ -0,0 +1 @@
declare module "lesspass";

+ 12
- 0
sandbox/lesspass-web-component/tailwind.config.js View File

@@ -0,0 +1,12 @@
module.exports = {
future: {
removeDeprecatedGapUtilities: true,
purgeLayersByDefault: true,
},
purge: false,
theme: {
extend: {},
},
variants: {},
plugins: [],
};

+ 25
- 0
sandbox/lesspass-web-component/tsconfig.json View File

@@ -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"
]
}

+ 12125
- 0
sandbox/lesspass-web-component/yarn.lock
File diff suppressed because it is too large
View File


Loading…
Cancel
Save