Преглед на файлове

reveal sandbox lesspass in react

pull/584/head
Guillaume Vincent преди 3 години
родител
ревизия
6a8b0c1cd9
променени са 54 файла, в които са добавени 14630 реда и са изтрити 2 реда
  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. Двоични данни
      sandbox/lesspass-web-component/public/favicon.ico
  6. +40
    -0
      sandbox/lesspass-web-component/public/index.html
  7. Двоични данни
      sandbox/lesspass-web-component/public/logo192.png
  8. Двоични данни
      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 Целия файл

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

+ 24
- 0
sandbox/lesspass-web-component/.gitignore Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

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

Двоични данни
sandbox/lesspass-web-component/public/favicon.ico Целия файл

Преди След

+ 40
- 0
sandbox/lesspass-web-component/public/index.html Целия файл

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

Двоични данни
sandbox/lesspass-web-component/public/logo192.png Целия файл

Преди След
Ширина: 600  |  Височина: 600  |  Големина: 16 KiB

Двоични данни
sandbox/lesspass-web-component/public/logo512.png Целия файл

Преди След
Ширина: 1600  |  Височина: 1600  |  Големина: 49 KiB

+ 25
- 0
sandbox/lesspass-web-component/public/manifest.json Целия файл

@@ -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 Целия файл

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

+ 258
- 0
sandbox/lesspass-web-component/src/App.test.tsx Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

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

+ 14
- 0
sandbox/lesspass-web-component/src/services/api.ts Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

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

+ 12
- 0
sandbox/lesspass-web-component/tailwind.config.js Целия файл

@@ -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 Целия файл

@@ -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
Файловите разлики са ограничени, защото са твърде много
Целия файл


Зареждане…
Отказ
Запис