瀏覽代碼

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 查看文件

Before After

+ 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 查看文件

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

二進制
sandbox/lesspass-web-component/public/logo512.png 查看文件

Before After
Width: 1600  |  Height: 1600  |  Size: 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
文件差異過大導致無法顯示
查看文件


Loading…
取消
儲存