ソースを参照

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
ファイル差分が大きすぎるため省略します
ファイルの表示


読み込み中…
キャンセル
保存