├── .eslintignore
├── src
├── components
│ ├── ui
│ │ ├── TextButton
│ │ │ ├── style.css
│ │ │ └── index.js
│ │ ├── Modal
│ │ │ ├── Footer.js
│ │ │ ├── Content.js
│ │ │ ├── Wrapper.js
│ │ │ ├── index.css
│ │ │ └── index.js
│ │ ├── Link.js
│ │ ├── NotificationStack
│ │ │ ├── index.css
│ │ │ └── index.js
│ │ ├── ConfirmModal.js
│ │ └── SteppedModal
│ │ │ └── index.js
│ ├── auth
│ │ ├── ScopeModal
│ │ │ ├── index.css
│ │ │ └── index.js
│ │ ├── style.css
│ │ ├── formUnlock.js
│ │ ├── formLogin2FA.js
│ │ ├── formLogin.js
│ │ └── formSignU2F.js
│ ├── settings
│ │ └── security
│ │ │ ├── index.css
│ │ │ ├── TwoFactor
│ │ │ ├── AddU2FModal
│ │ │ │ ├── sign-u2f.png
│ │ │ │ ├── index.css
│ │ │ │ ├── index.js
│ │ │ │ ├── presentation.js
│ │ │ │ ├── formName.js
│ │ │ │ └── formRegisterKey.js
│ │ │ ├── SetupTOTPModal
│ │ │ │ ├── index.css
│ │ │ │ ├── index.js
│ │ │ │ ├── presentation.js
│ │ │ │ ├── confirmCode.js
│ │ │ │ └── sharedSecret.js
│ │ │ ├── style.css
│ │ │ ├── SaveRecoveryCodeModal
│ │ │ │ ├── index.css
│ │ │ │ ├── index.js
│ │ │ │ ├── formTestCode.js
│ │ │ │ └── presentation.js
│ │ │ ├── U2FKeyList
│ │ │ │ ├── style.css
│ │ │ │ └── U2FKeyList.js
│ │ │ └── index.js
│ │ │ └── index.js
│ ├── header
│ │ ├── style.css
│ │ └── index.js
│ └── app.js
├── assets
│ ├── favicon.ico
│ └── icons
│ │ ├── favicon-16x16.png
│ │ ├── favicon-32x32.png
│ │ ├── mstile-150x150.png
│ │ ├── apple-touch-icon.png
│ │ ├── android-chrome-192x192.png
│ │ └── android-chrome-512x512.png
├── routes
│ ├── home
│ │ ├── style.css
│ │ └── index.js
│ ├── settings
│ │ ├── index.css
│ │ └── index.js
│ └── dashboard
│ │ └── index.js
├── tests
│ ├── __mocks__
│ │ ├── index.js
│ │ ├── u2fApi.js
│ │ ├── devtools.js
│ │ ├── ReactModalMocks.js
│ │ ├── fileMocks.js
│ │ └── browserMocks.js
│ ├── __snapshots__
│ │ └── header.test.js.snap
│ ├── header.test.js
│ ├── components
│ │ ├── auth
│ │ │ ├── formLogin2FA.test.js
│ │ │ ├── __snapshots__
│ │ │ │ ├── formSignU2F.test.js.snap
│ │ │ │ └── formLogin2FA.test.js.snap
│ │ │ └── formSignU2F.test.js
│ │ └── settings
│ │ │ └── security
│ │ │ └── TwoFactor
│ │ │ ├── AddU2FModal
│ │ │ ├── presentation.test.js
│ │ │ ├── __snapshots__
│ │ │ │ ├── formName.test.js.snap
│ │ │ │ ├── presentation.test.js.snap
│ │ │ │ └── formRegisterKey.test.js.snap
│ │ │ ├── formName.test.js
│ │ │ └── formRegisterKey.test.js
│ │ │ ├── SetupTOTPModal
│ │ │ ├── presentation.test.js
│ │ │ ├── __snapshots__
│ │ │ │ ├── presentation.test.js.snap
│ │ │ │ └── sharedSecret.test.js.snap
│ │ │ └── sharedSecret.test.js
│ │ │ ├── U2FKeyList.test.js
│ │ │ ├── __snapshots__
│ │ │ ├── U2FKeyList.test.js.snap
│ │ │ └── index.test.js.snap
│ │ │ ├── index.test.js
│ │ │ └── SaveRecoveryCodeModal
│ │ │ ├── __snapshots__
│ │ │ ├── presentation.test.js.snap
│ │ │ └── formTestCode.test.js.snap
│ │ │ ├── formTestCode.test.js
│ │ │ └── presentation.test.js
│ ├── helpers
│ │ ├── stateFormatter.test.js
│ │ ├── __snapshots__
│ │ │ └── stateFormatter.test.js.snap
│ │ └── u2f.test.js
│ ├── testsHelpers
│ │ └── storeTools.js
│ └── actions
│ │ └── authentication.test.js
├── app-id.json
├── helpers
│ ├── toActions.js
│ ├── notification.js
│ ├── plan.js
│ ├── text.js
│ ├── store.js
│ ├── stateFormatter.js
│ └── u2f.js
├── index.js
├── lib
│ └── hookErrorActions.js
├── manifest.json
├── .babelrc
├── style
│ └── index.css
└── actions
│ ├── scope.js
│ ├── settings
│ ├── addU2FKey.js
│ ├── enableTOTP.js
│ └── index.js
│ └── authentication.js
├── .gitignore
├── .prettierrc
├── .editorconfig
├── circle.yml
├── env
├── configDefault.js
├── config.constants.js
└── config.js
├── preact.config.js
├── .github
└── ISSUE_TEMPLATE
│ ├── feature_request.md
│ └── bug_report.md
├── tasks
├── helpers
│ └── log.js
├── setupConfig.js
└── deploy.js
├── LICENSE
├── README.md
└── package.json
/.eslintignore:
--------------------------------------------------------------------------------
1 | src/tests
2 |
--------------------------------------------------------------------------------
/src/components/ui/TextButton/style.css:
--------------------------------------------------------------------------------
1 | .a {
2 | }
3 |
--------------------------------------------------------------------------------
/src/components/auth/ScopeModal/index.css:
--------------------------------------------------------------------------------
1 | .scopeFormModal {
2 | }
3 |
--------------------------------------------------------------------------------
/src/assets/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/protonmail/account/master/src/assets/favicon.ico
--------------------------------------------------------------------------------
/src/routes/home/style.css:
--------------------------------------------------------------------------------
1 | .home {
2 | padding: 56px 20px;
3 | min-height: 100%;
4 | width: 100%;
5 | }
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | /build
3 | /*.log
4 | *.lock
5 | package-lock.json
6 | src/config.js
7 | coverage
8 | dist
9 |
--------------------------------------------------------------------------------
/src/assets/icons/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/protonmail/account/master/src/assets/icons/favicon-16x16.png
--------------------------------------------------------------------------------
/src/assets/icons/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/protonmail/account/master/src/assets/icons/favicon-32x32.png
--------------------------------------------------------------------------------
/src/assets/icons/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/protonmail/account/master/src/assets/icons/mstile-150x150.png
--------------------------------------------------------------------------------
/src/assets/icons/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/protonmail/account/master/src/assets/icons/apple-touch-icon.png
--------------------------------------------------------------------------------
/src/tests/__mocks__/index.js:
--------------------------------------------------------------------------------
1 | import './ReactModalMocks';
2 | import './browserMocks';
3 | import './devtools';
4 | import './u2fApi';
5 |
--------------------------------------------------------------------------------
/src/assets/icons/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/protonmail/account/master/src/assets/icons/android-chrome-192x192.png
--------------------------------------------------------------------------------
/src/assets/icons/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/protonmail/account/master/src/assets/icons/android-chrome-512x512.png
--------------------------------------------------------------------------------
/src/components/settings/security/index.css:
--------------------------------------------------------------------------------
1 | .top {
2 | display: flex;
3 | width: 100%;
4 | }
5 |
6 | .panel {
7 | flex: 1;
8 | }
9 |
--------------------------------------------------------------------------------
/src/tests/__mocks__/u2fApi.js:
--------------------------------------------------------------------------------
1 | import { isSupported } from 'u2f-api';
2 |
3 | jest.mock('u2f-api');
4 | isSupported.mockImplementation(() => true);
5 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120,
3 | "arrowParens": "always",
4 | "singleQuote": true,
5 | "tabWidth": 4,
6 | "proseWrap": "never"
7 | }
8 |
--------------------------------------------------------------------------------
/src/tests/__mocks__/devtools.js:
--------------------------------------------------------------------------------
1 | import devtools from 'unistore/devtools';
2 |
3 | jest.mock('unistore/devtools');
4 | devtools.mockImplementation((store) => store);
5 |
--------------------------------------------------------------------------------
/src/tests/__mocks__/ReactModalMocks.js:
--------------------------------------------------------------------------------
1 | import ReactModal from 'react-modal';
2 | jest.mock('react-modal');
3 |
4 | ReactModal.setAppElement.mockImplementation((store) => store);
5 |
--------------------------------------------------------------------------------
/src/components/auth/style.css:
--------------------------------------------------------------------------------
1 | .twoFactorOption {
2 | border-bottom: 1px solid black;
3 | border-left: 1px solid black;
4 | padding: 5px;
5 | margin-left: 10px;
6 | }
7 |
--------------------------------------------------------------------------------
/src/components/settings/security/TwoFactor/AddU2FModal/sign-u2f.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/protonmail/account/master/src/components/settings/security/TwoFactor/AddU2FModal/sign-u2f.png
--------------------------------------------------------------------------------
/src/components/ui/Modal/Footer.js:
--------------------------------------------------------------------------------
1 | import { h } from 'preact';
2 |
3 | import styles from './index.css';
4 |
5 | const Footer = ({ children }) => ;
6 |
7 | export default Footer;
8 |
--------------------------------------------------------------------------------
/src/tests/__mocks__/fileMocks.js:
--------------------------------------------------------------------------------
1 | // This fixed an error related to the CSS and loading gif breaking my Jest test
2 | // See https://facebook.github.io/jest/docs/en/webpack.html#handling-static-assets
3 | module.exports = 'test-file-stub';
4 |
--------------------------------------------------------------------------------
/src/components/ui/Link.js:
--------------------------------------------------------------------------------
1 | import { h } from 'preact';
2 |
3 | const Link = ({ href, children }) => (
4 |
5 | {children}
6 |
7 | );
8 |
9 | export default Link;
10 |
--------------------------------------------------------------------------------
/src/app-id.json:
--------------------------------------------------------------------------------
1 | {
2 | "trustedFacets": [
3 | {
4 | "version": {
5 | "major": 1,
6 | "minor": 0
7 | },
8 | "ids": ["https://account.protonmail.red"]
9 | }
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/src/components/ui/Modal/Content.js:
--------------------------------------------------------------------------------
1 | import { h } from 'preact';
2 |
3 | import styles from './index.css';
4 |
5 | const ModalContent = (props) =>
{props.children}
;
6 |
7 | export default ModalContent;
8 |
--------------------------------------------------------------------------------
/src/routes/settings/index.css:
--------------------------------------------------------------------------------
1 | .security {
2 | margin-top: 100px;
3 | display: flex
4 | }
5 |
6 | .nav {
7 | flex: 1;
8 | }
9 |
10 | .selected {
11 | color: black;
12 | text-underline: none;
13 | }
14 |
15 | .content {
16 | flex: 10;
17 | }
18 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://editorconfig.org
2 | root = true
3 |
4 | [*]
5 | indent_size = 4
6 | indent_style = space
7 | end_of_line = lf
8 | charset = utf-8
9 | trim_trailing_whitespace = true
10 | insert_final_newline = true
11 |
12 | [*.md]
13 | trim_trailing_whitespace = false
14 |
--------------------------------------------------------------------------------
/src/tests/__snapshots__/header.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Initial Test of the Header Header renders 3 nav items 1`] = `
4 |
10 | `;
11 |
--------------------------------------------------------------------------------
/src/components/ui/Modal/Wrapper.js:
--------------------------------------------------------------------------------
1 | import { h } from 'preact';
2 | import styles from './index.css';
3 |
4 | const Wrapper = ({ children, onSubmit, onReset }) => (
5 |
8 | );
9 |
10 | export default Wrapper;
11 |
--------------------------------------------------------------------------------
/src/tests/header.test.js:
--------------------------------------------------------------------------------
1 | import render from 'preact-render-to-string';
2 |
3 | import Header from '../components/header';
4 |
5 | describe('Initial Test of the Header', () => {
6 | test('Header renders 3 nav items', () => {
7 | const tree = render();
8 | expect(tree).toMatchSnapshot();
9 | });
10 | });
11 |
--------------------------------------------------------------------------------
/src/helpers/toActions.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Add suffix Actions at the end of each functions.
3 | * As unistore merge states and actions it prevents conflicts
4 | * @param {Object} actions
5 | * @return {Object}
6 | */
7 | export default function toActions(actions = {}) {
8 | return Object.keys(actions).reduce((acc, key) => {
9 | acc[`${key}Action`] = actions[key];
10 | return acc;
11 | }, {});
12 | }
13 |
--------------------------------------------------------------------------------
/src/components/settings/security/TwoFactor/SetupTOTPModal/index.css:
--------------------------------------------------------------------------------
1 | .grid {
2 | width: 50%
3 | }
4 |
5 | .row {
6 | display: flex;
7 | flex-direction: row;
8 | justify-content: space-between;
9 | }
10 |
11 | .label {
12 | margin: auto 0;
13 | flex: 1;
14 | }
15 |
16 | .value {
17 | flex: 3
18 | }
19 |
20 |
21 | .result{
22 | flex: 2;
23 | }
24 |
25 | .description {
26 | flex: 2;
27 | }
28 |
29 | .code {
30 | }
31 |
--------------------------------------------------------------------------------
/src/helpers/notification.js:
--------------------------------------------------------------------------------
1 | import appDispatcher from 'frontend-commons/src/utils/appDispatcher';
2 |
3 | const dispatcher = appDispatcher('notification');
4 |
5 | const action = (type) => (message, opt = {}) => dispatcher.input(type, { message, opt });
6 | export const success = action('success');
7 | export const error = (err) => action('error')(err.message, err);
8 | export const info = action('info');
9 |
10 | export const onInput = dispatcher.onInput;
11 |
--------------------------------------------------------------------------------
/src/helpers/plan.js:
--------------------------------------------------------------------------------
1 | const searchParams = new URLSearchParams(window.location.search);
2 |
3 | export const getDefaultConfigPlan = ({ Cycle = 12, Currency = 'EUR' } = {}) => ({
4 | Currency: searchParams.get('currency') || Currency,
5 | Cycle: +(searchParams.get('billing') || Cycle),
6 | Name: searchParams.get('plan') || 'vpnplus'
7 | });
8 |
9 | export const cycleString = ({ Cycle }, isMonth = false) => (Cycle === 12 && !isMonth ? 'year' : 'month');
10 |
--------------------------------------------------------------------------------
/circle.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | jobs:
3 | build:
4 | docker:
5 | - image: circleci/node:10.9.0-stretch-browsers
6 | steps:
7 | - checkout
8 | - restore_cache:
9 | key: npm-deps-{{ checksum "package.json" }}
10 | - run: npm i
11 | - save_cache:
12 | key: npm-deps-{{ checksum "package.json" }}
13 | paths:
14 | - node_modules
15 | - run: npm run lint
16 | - run: npm test
17 |
--------------------------------------------------------------------------------
/src/components/settings/security/TwoFactor/style.css:
--------------------------------------------------------------------------------
1 | .twoFactor {
2 | display: flex;
3 | flex-direction: column;
4 | align-items: stretch;
5 | }
6 |
7 | .item {
8 | display: flex;
9 | flex-direction: row;
10 | justify-content: space-around;
11 | align-items: center;
12 | }
13 |
14 | .lastItem:after {
15 | content: "";
16 | flex: 2;
17 | }
18 |
19 | .description {
20 | flex: 2;
21 | }
22 |
23 | .action {
24 | flex: 1;
25 | }
26 |
--------------------------------------------------------------------------------
/src/helpers/text.js:
--------------------------------------------------------------------------------
1 | import { saveAs } from 'file-saver';
2 |
3 | /**
4 | * Download the given lines as a file.
5 | * @param {string} name - the name of the file
6 | * @param {string[]} lines - the lines to put in the file
7 | * @param {string} [type='text/plain;charset=utf-8'] - the mime type of the file
8 | */
9 | export const downloadAsFile = (name, lines = [], type = 'text/plain;charset=utf-8') => {
10 | const blob = new Blob([lines.join('\r\n')], { type });
11 | saveAs(blob, name);
12 | };
13 |
--------------------------------------------------------------------------------
/src/components/ui/TextButton/index.js:
--------------------------------------------------------------------------------
1 | import { h } from 'preact';
2 |
3 | import style from './style.css';
4 |
5 | /**
6 | * Button that has the appearance of a link.
7 | * @param {Function} onClick - handle for the click event.
8 | * @param {preact.Component[]} children
9 | * @returns {preact.Component}
10 | */
11 | const TextButton = ({ onClick, children }) => (
12 |
13 | {children}
14 |
15 | );
16 |
17 | export default TextButton;
18 |
--------------------------------------------------------------------------------
/env/configDefault.js:
--------------------------------------------------------------------------------
1 | const PACKAGE = require('../package');
2 |
3 | module.exports = {
4 | app_version: PACKAGE.version,
5 | api_version: '3',
6 | date_version: new Date().toDateString(),
7 | year: new Date().getFullYear(),
8 | clientID: 'WebVPN',
9 | clientSecret: 'e601ca139540a6e55a25071c3a5b9557',
10 | articleLink: 'https://protonmail.com/blog/protonmail-v3-14-release-notes/',
11 | changelogPath: '',
12 | url: 'https://account.protonmail.red',
13 | loadI18n: false,
14 | translations: []
15 | };
16 |
--------------------------------------------------------------------------------
/src/components/settings/security/TwoFactor/SaveRecoveryCodeModal/index.css:
--------------------------------------------------------------------------------
1 | .list {
2 | display: flex;
3 | flex-direction: row;
4 | flex-wrap: wrap;
5 | align-self: stretch;
6 | padding: 0 5%;
7 | }
8 |
9 | .item {
10 | width: 15%;
11 | margin-left: 10%;
12 | }
13 |
14 | .code {
15 | }
16 |
17 | .actions {
18 | display: flex;
19 | justify-content: space-around;
20 | align-self: stretch;
21 | text-align: left;
22 | }
23 |
24 | .description {
25 | flex: 2;
26 | }
27 | .result{
28 | flex: 2;
29 | }
30 |
--------------------------------------------------------------------------------
/src/tests/components/auth/formLogin2FA.test.js:
--------------------------------------------------------------------------------
1 | import render from 'preact-render-to-string';
2 |
3 | import FormLogin2FA from '../../../components/auth/formLogin2FA';
4 | import { renderProvided } from '../../testsHelpers/storeTools';
5 |
6 | describe('Testing FromLogin2FA', () => {
7 | test('without U2F', () => {
8 | expect(render( )).toMatchSnapshot();
9 | });
10 |
11 | test('with U2F', () => {
12 | expect(renderProvided( )).toMatchSnapshot();
13 | });
14 | });
15 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import { h } from 'preact';
2 | import { Provider } from 'unistore/full/preact';
3 |
4 | import App from './components/app';
5 | import hookErrorActions from './lib/hookErrorActions';
6 | import store from './helpers/store';
7 |
8 | import './style';
9 |
10 | hookErrorActions(store, (e) => {
11 | console.log('-------- ¯\\_(ツ)_/¯ -------');
12 | console.error(e);
13 | console.log('---------------------------');
14 | });
15 |
16 | const main = () => (
17 |
18 |
19 |
20 | );
21 |
22 | export default main;
23 |
--------------------------------------------------------------------------------
/preact.config.js:
--------------------------------------------------------------------------------
1 | const preactCliLodash = require('preact-cli-lodash');
2 |
3 | module.exports = function (config, env, helpers) {
4 | config.node.process = 'mock';
5 | config.node.Buffer = true;
6 | // console.log(config);
7 |
8 | preactCliLodash(config);
9 |
10 | const loaders = config.module.loaders;
11 | const babelLoader = loaders.find(loader => loader.loader === 'babel-loader');
12 | babelLoader.exclude = /node_modules/;
13 |
14 | const [ { index = null } = {} ] = helpers.getPluginsByName(config, 'UglifyJsPlugin');
15 | (index !== null) && config.plugins.splice(index, 1);
16 | };
17 |
--------------------------------------------------------------------------------
/src/components/ui/NotificationStack/index.css:
--------------------------------------------------------------------------------
1 | .notificationStack {
2 | position: fixed;
3 | width: 40%;
4 | margin: 0 auto;
5 | left: 0;
6 | right: 0;
7 | top: 20px;
8 | z-index: 51;
9 | display: flex;
10 | flex-direction: column;
11 | justify-content: stretch;
12 | }
13 |
14 | .notification {
15 | flex: 1;
16 | margin: 1px 0;
17 | list-style-type: none;
18 | }
19 |
20 | .success {
21 | background-color: #52ff49;
22 | }
23 |
24 | .error {
25 | background-color: #ff4646;
26 | }
27 |
28 | .info {
29 | background-color: #a7cfff;
30 |
31 | }
32 |
--------------------------------------------------------------------------------
/src/lib/hookErrorActions.js:
--------------------------------------------------------------------------------
1 | const noop = () => {};
2 |
3 | /**
4 | * Manage errors from actions
5 | * -- Mutate the store --
6 | * @link {https://github.com/developit/unistore/issues/83#issuecomment-389848046}
7 | * @param {Object} store
8 | * @param {Function} hook
9 | * @return {void}
10 | */
11 | export default function hookErrorActions(store, hook = noop) {
12 | const action = store.action;
13 | store.action = function(...args) {
14 | const fn = action.apply(this, args);
15 | return function(...args) {
16 | fn.apply(this, args).catch(hook);
17 | };
18 | };
19 | }
20 |
--------------------------------------------------------------------------------
/src/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "proton-acount",
3 | "short_name": "proton-acount",
4 | "start_url": "/",
5 | "display": "standalone",
6 | "orientation": "portrait",
7 | "background_color": "#fff",
8 | "theme_color": "#673ab8",
9 | "icons": [
10 | {
11 | "src": "/assets/icons/android-chrome-192x192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "/assets/icons/android-chrome-512x512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ]
21 | }
22 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 |
5 | ---
6 |
7 | **Is your feature request related to a problem? Please describe.**
8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
9 |
10 | **Describe the solution you'd like**
11 | A clear and concise description of what you want to happen.
12 |
13 | **Describe alternatives you've considered**
14 | A clear and concise description of any alternative solutions or features you've considered.
15 |
16 | **Additional context**
17 | Add any other context or screenshots about the feature request here.
18 |
--------------------------------------------------------------------------------
/src/tests/__mocks__/browserMocks.js:
--------------------------------------------------------------------------------
1 | // Mock Browser API's which are not supported by JSDOM, e.g. ServiceWorker, LocalStorage
2 | /**
3 | * An example how to mock localStorage is given below 👇
4 | */
5 |
6 | /*
7 | // Mocks localStorage
8 | const localStorageMock = (function() {
9 | let store = {};
10 |
11 | return {
12 | getItem: (key) => store[key] || null,
13 | setItem: (key, value) => store[key] = value.toString(),
14 | clear: () => store = {}
15 | };
16 |
17 | })();
18 |
19 | Object.defineProperty(window, 'localStorage', {
20 | value: localStorageMock
21 | }); */
22 | window.addEventListener = jest.fn();
23 | window.sessionStorage = {
24 | clear: jest.fn()
25 | };
26 |
--------------------------------------------------------------------------------
/src/components/settings/security/TwoFactor/U2FKeyList/style.css:
--------------------------------------------------------------------------------
1 | .list {
2 | display: flex;
3 | flex-direction: column;
4 | padding: 0;
5 | }
6 |
7 | .listHeader {
8 | display: flex;
9 | flex-direction: row;
10 | justify-content: space-between;
11 | }
12 |
13 | .listElement {
14 | display: flex;
15 | justify-content: space-between;
16 | align-items: center;
17 | border: 1px solid black;
18 | padding: 2px;
19 | }
20 |
21 | .listElement:not(:last-child){
22 | border-bottom: 0;
23 | }
24 |
25 | .listElementHeaderCompromised {
26 | text-decoration: line-through;
27 | }
28 |
29 | .listElementHeader {
30 | margin-right: 'auto'
31 | }
32 |
33 |
--------------------------------------------------------------------------------
/src/components/settings/security/TwoFactor/AddU2FModal/index.css:
--------------------------------------------------------------------------------
1 | .name {
2 | display: flex;
3 | justify-content: space-between;
4 | align-self: stretch;
5 | }
6 |
7 | .nameLabel {
8 | flex: 1;
9 | }
10 |
11 | .nameInputContainer {
12 | flex: 4;
13 | }
14 |
15 | .nameTextInput {
16 | width: 100%
17 | }
18 |
19 | .container {
20 | align-items: stretch;
21 | }
22 |
23 | .status {
24 | margin: auto 20%;
25 | }
26 |
27 | .row{
28 | display: flex;
29 | flex-direction: row;
30 | justify-content: space-between;
31 | }
32 |
33 | .text {
34 | flex: 1;
35 | }
36 |
37 | .text:first-child {
38 | text-align: left;
39 | }
40 |
41 | .text.nameLabel {
42 | color: #9592d1;
43 | }
44 |
--------------------------------------------------------------------------------
/src/tests/components/auth/__snapshots__/formSignU2F.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`testing FormSignU2F component... U2F not available on this browser 1`] = `
4 |
5 |
Your browser is not supported, please use another 2FA method instead
6 |
7 | `;
8 |
9 | exports[`testing FormSignU2F component... correct state 1`] = `
10 |
11 |
Activate your security key...
12 |
13 | `;
14 |
15 | exports[`testing FormSignU2F component... error state 1`] = `
16 |
17 |
Activate your security key...
18 |
19 | An error occurred.
20 | Retry
21 |
22 |
23 | `;
24 |
25 | exports[`testing FormSignU2F component... success state 1`] = `
26 |
29 | `;
30 |
--------------------------------------------------------------------------------
/src/components/settings/security/index.js:
--------------------------------------------------------------------------------
1 | import { h } from 'preact';
2 |
3 | import TwoFactorSettings from './TwoFactor';
4 |
5 | import styles from './index.css';
6 |
7 | /**
8 | * Manages the setting security view.
9 | * @return {Component}
10 | */
11 | const view = ({ user }) => (
12 |
13 |
Security
14 |
15 |
16 |
17 |
18 |
19 |
20 |
Session Management
21 |
22 |
23 |
24 |
Authentication Logs
25 |
26 |
27 | );
28 |
29 | export default view;
30 |
--------------------------------------------------------------------------------
/src/routes/home/index.js:
--------------------------------------------------------------------------------
1 | import { h } from 'preact';
2 | import { connect } from 'unistore/full/preact';
3 |
4 | import style from './style';
5 | import authActions from '../../actions/authentication';
6 | import FormLogin from '../../components/auth/formLogin';
7 | import FormUnlock from '../../components/auth/formUnlock';
8 | import FormLogin2FA from '../../components/auth/formLogin2FA';
9 |
10 | export default connect(
11 | ['auth'],
12 | authActions
13 | )(({ auth, loginAction, login2FAAction, unlockAction }) => {
14 | console.log('LOGIN', auth);
15 | return (
16 |
17 | {auth.step === 'login' && }
18 | {auth.step === 'unlock' && }
19 | {auth.step === '2fa' && }
20 |
21 | );
22 | });
23 |
--------------------------------------------------------------------------------
/src/routes/dashboard/index.js:
--------------------------------------------------------------------------------
1 | import { h } from 'preact';
2 | import { connect } from 'unistore/full/preact';
3 | import TextButton from '../../components/ui/TextButton';
4 | import { info } from '../../helpers/notification';
5 |
6 | export default connect(['auth', 'config'])(({ auth, config }) => {
7 | console.log('DASHBOARD', auth);
8 | return (
9 |
10 |
11 | Bonjour {auth.user.Name}
12 |
13 |
14 |
{
16 | const id = Math.floor(Math.random() * 100) + 1;
17 | info('Test notification ' + id, { id });
18 | }}
19 | >
20 | Send a test notification
21 |
22 |
23 |
{JSON.stringify(config, null, 2)}
24 |
25 | );
26 | });
27 |
--------------------------------------------------------------------------------
/src/tests/helpers/stateFormatter.test.js:
--------------------------------------------------------------------------------
1 | import { extended } from '../../helpers/stateFormatter';
2 |
3 | const state = {
4 | a: {
5 | c: {
6 | e: 1,
7 | g: 'arabica'
8 | }
9 | },
10 | b: {
11 | d: {
12 | f: 2
13 | },
14 | h: 256
15 | }
16 | };
17 |
18 | describe('"extended" tests for stateFormatter', () => {
19 | test('only includes root element', () => {
20 | expect(extended(state, 'a', { d: 1 })).toMatchSnapshot();
21 | });
22 | test('includes all non root element', () => {
23 | expect(extended(state, 'b', { h: 256 })).toMatchSnapshot();
24 | });
25 | test('correctly updates element', () => {
26 | expect(extended(state, 'b.d', { f: -1 })).toMatchSnapshot();
27 | });
28 | test('correctly behave when array is given', () => {
29 | expect(extended(state, ['b.d', 'f'], { n: -1 })).toMatchSnapshot();
30 | });
31 | });
32 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 |
5 | ---
6 |
7 | **Describe the bug**
8 | A clear and concise description of what the bug is.
9 |
10 | **To Reproduce**
11 | Steps to reproduce the behavior:
12 | 1. Go to '...'
13 | 2. Click on '....'
14 | 3. Scroll down to '....'
15 | 4. See error
16 |
17 | **Expected behavior**
18 | A clear and concise description of what you expected to happen.
19 |
20 | **Screenshots**
21 | If applicable, add screenshots to help explain your problem.
22 |
23 | **Desktop (please complete the following information):**
24 | - OS: [e.g. iOS]
25 | - Browser [e.g. chrome, safari]
26 | - Version [e.g. 22]
27 |
28 | **Smartphone (please complete the following information):**
29 | - Device: [e.g. iPhone6]
30 | - OS: [e.g. iOS8.1]
31 | - Browser [e.g. stock browser, safari]
32 | - Version [e.g. 22]
33 |
34 | **Additional context**
35 | Add any other context about the problem here.
36 |
--------------------------------------------------------------------------------
/src/components/ui/Modal/index.css:
--------------------------------------------------------------------------------
1 | .modal {
2 | top: 50%;
3 | left: 50%;
4 | right: auto;
5 | bottom: auto;
6 | width: 50%;
7 | margin-right: -50%;
8 | transform: translate(-50%, -50%);
9 | display: flex;
10 | flex-direction: column;
11 | align-items: stretch;
12 | padding: 0;
13 | position: absolute;
14 | border: 1px solid #ccc;
15 | background: #fff;
16 | overflow: auto;
17 | -webkit-overflow-scrolling: touch;
18 | border-radius: 4px;
19 | outline: none;
20 | }
21 |
22 | .header {
23 | border-bottom: 1px solid #ccc;
24 | text-align: center;
25 | }
26 |
27 | .content {
28 | display: flex;
29 | flex-direction: column;
30 | align-items: center;
31 | justify-content: space-around;
32 | padding: 20px;
33 | }
34 |
35 | .wrapper {
36 | }
37 |
38 | .footer {
39 | display: flex;
40 | justify-content: space-between;
41 | padding: 20px;
42 | border-top: #ccc solid 1px;
43 | }
44 |
--------------------------------------------------------------------------------
/src/helpers/store.js:
--------------------------------------------------------------------------------
1 | import { createStore } from 'unistore/full/preact';
2 | import devtools from 'unistore/devtools';
3 |
4 | import authActions from '../actions/authentication';
5 | import settingsActions from '../actions/settings';
6 | import scopeActions from '../actions/scope';
7 |
8 | export const initialState = {
9 | auth: {
10 | isLoggedIn: false,
11 | user: {},
12 | step: 'login',
13 | twoFactorResponse: {}
14 | },
15 | scope: {
16 | creds: null,
17 | response: null
18 | },
19 | settings: {
20 | addU2FKey: {},
21 | reset2FARecoveryCodes: {},
22 | setupTOTP: {}
23 | }
24 | };
25 | const store = process.env.NODE_ENV === 'production' ? createStore(initialState) : devtools(createStore(initialState));
26 |
27 | export const actions = (store) => ({
28 | ...authActions(store),
29 | ...settingsActions(store),
30 | ...scopeActions(store)
31 | });
32 |
33 | export default store;
34 |
--------------------------------------------------------------------------------
/src/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | [
4 | "preact-cli/babel",
5 | {
6 | "modules": "commonjs"
7 | }
8 | ],
9 | [
10 | "env",
11 | {
12 | "targets": {
13 | "browsers": [
14 | "ie 11"
15 | ]
16 | }
17 | }
18 | ]
19 | ],
20 | "plugins": [
21 | [
22 | "babel-plugin-transform-react-jsx",
23 | {
24 | "pragma": "h"
25 | }
26 | ],
27 | "babel-plugin-transform-object-rest-spread",
28 | "babel-plugin-lodash",
29 | [
30 | "babel-plugin-transform-runtime",
31 | {
32 | "helpers": false,
33 | "polyfill": false,
34 | "regenerator": true
35 | }
36 | ],
37 | "transform-class-properties"
38 | ]
39 | }
40 |
--------------------------------------------------------------------------------
/src/tests/helpers/__snapshots__/stateFormatter.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`"extended" tests for stateFormatter correctly behave when array is given 1`] = `
4 | Object {
5 | "b.d": Object {
6 | "f": Object {
7 | "n": -1,
8 | },
9 | },
10 | }
11 | `;
12 |
13 | exports[`"extended" tests for stateFormatter correctly updates element 1`] = `
14 | Object {
15 | "b": Object {
16 | "d": Object {
17 | "f": -1,
18 | },
19 | "h": 256,
20 | },
21 | }
22 | `;
23 |
24 | exports[`"extended" tests for stateFormatter includes all non root element 1`] = `
25 | Object {
26 | "b": Object {
27 | "d": Object {
28 | "f": 2,
29 | },
30 | "h": 256,
31 | },
32 | }
33 | `;
34 |
35 | exports[`"extended" tests for stateFormatter only includes root element 1`] = `
36 | Object {
37 | "a": Object {
38 | "c": Object {
39 | "e": 1,
40 | "g": "arabica",
41 | },
42 | "d": 1,
43 | },
44 | }
45 | `;
46 |
--------------------------------------------------------------------------------
/src/components/auth/ScopeModal/index.js:
--------------------------------------------------------------------------------
1 | import { h } from 'preact';
2 |
3 | import ScopeFormModal from './ScopeFormModal';
4 | import store from '../../../helpers/store';
5 | import actions from '../../../actions/scope';
6 |
7 | export const steps = (scope) => {
8 | if (scope !== 'password') {
9 | return [];
10 | }
11 | return [
12 | {
13 | title: 'Confirm your identity',
14 | component({ onNextStep, onPreviousStep, onSkipStep, message }) {
15 | return (
16 |
22 | );
23 | }
24 | }
25 | ];
26 | };
27 |
28 | export const beforeDismiss = () => {
29 | const { resetScopeStateAction } = actions(store);
30 | resetScopeStateAction(store.getState());
31 | };
32 |
--------------------------------------------------------------------------------
/tasks/helpers/log.js:
--------------------------------------------------------------------------------
1 | const chalk = require('chalk');
2 |
3 | const warn = (msg) => {
4 | console.log();
5 | console.log(`${chalk.magenta('⚠')} ${chalk.magenta(msg)}.`);
6 | console.log();
7 | };
8 |
9 | const success = (msg, { time } = {}) => {
10 | const txt = chalk.green(` ${chalk.bold('✓')} ${msg} `);
11 | const message = [txt, time && `(${time})`].filter(Boolean).join('');
12 | console.log();
13 | console.log(message);
14 | };
15 |
16 | const title = (msg) => {
17 | console.log('~', chalk.bgYellow(chalk.black(msg)), '~');
18 | console.log();
19 | };
20 |
21 | const json = (data) => {
22 | console.log();
23 | console.log(JSON.stringify(data, null, 2));
24 | console.log();
25 | };
26 |
27 | const error = (e) => {
28 | console.log();
29 | console.log(chalk.red(' ⚠'), chalk.red(e.message));
30 | console.log();
31 | console.log();
32 | process.exit(1);
33 | };
34 |
35 | module.exports = {
36 | success,
37 | title,
38 | error,
39 | json,
40 | warn
41 | };
42 |
--------------------------------------------------------------------------------
/src/components/auth/formUnlock.js:
--------------------------------------------------------------------------------
1 | import { h } from 'preact';
2 |
3 | const formUnlock = ({ unlock }) => {
4 | const model = {};
5 |
6 | return (
7 |
28 | );
29 | };
30 |
31 | export default formUnlock;
32 |
--------------------------------------------------------------------------------
/src/components/header/style.css:
--------------------------------------------------------------------------------
1 | .header {
2 | position: fixed;
3 | left: 0;
4 | top: 0;
5 | width: 100%;
6 | height: 56px;
7 | padding: 0;
8 | background: #673AB7;
9 | box-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
10 | z-index: 50;
11 | }
12 |
13 | .header h1 {
14 | float: left;
15 | margin: 0;
16 | padding: 0 15px;
17 | font-size: 24px;
18 | line-height: 56px;
19 | font-weight: 400;
20 | color: #FFF;
21 | }
22 |
23 | .header nav {
24 | float: right;
25 | font-size: 100%;
26 | }
27 |
28 | .header nav a, .header button {
29 | display: inline-block;
30 | border: 0;
31 | height: 56px;
32 | line-height: 56px;
33 | padding: 0 15px;
34 | min-width: 50px;
35 | text-align: center;
36 | font-size: 1em;
37 | cursor: pointer;
38 | background: rgba(255,255,255,0);
39 | text-decoration: none;
40 | color: #FFF;
41 | will-change: background-color;
42 | }
43 |
44 | .header nav a:hover,
45 | .header nav a:active,
46 | .header nav button:hover,
47 | .header nav button:active {
48 | background: rgba(0,0,0,0.2);
49 | }
50 |
51 | .header nav button.active,
52 | .header nav a.active {
53 | background: rgba(0,0,0,0.4);
54 | }
55 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/style/index.css:
--------------------------------------------------------------------------------
1 | html, body {
2 | height: 100%;
3 | width: 100%;
4 | padding: 0;
5 | margin: 0;
6 | background: #FAFAFA;
7 | font-family: 'Helvetica Neue', arial, sans-serif;
8 | font-weight: 400;
9 | color: #444;
10 | -webkit-font-smoothing: antialiased;
11 | -moz-osx-font-smoothing: grayscale;
12 | }
13 |
14 | * {
15 | box-sizing: border-box;
16 | }
17 |
18 | #app {
19 | height: 100%;
20 | }
21 |
22 | .badge {
23 | border-radius: 2px;
24 | margin: 2px;
25 | padding: 2px 5px;
26 | color: white;
27 | font-weight: bold;
28 | }
29 |
30 | .badge-danger {
31 | background-color: red;
32 | }
33 |
34 | .alert {
35 | border-radius: 2px;
36 | margin: 2px;
37 | padding: 10px 15px;
38 | color: #629347;
39 | font-weight: bold;
40 | }
41 |
42 | .alert-info {
43 | background-color: #dfeed7;
44 | }
45 |
46 | .form-row {
47 | display: flex;
48 | align-self: stretch;
49 | justify-content: space-around;
50 | flex: 1;
51 | align-items: center;
52 | }
53 |
54 | .form-row:before, .form-row:after {
55 | content: '';
56 | flex: 1;
57 | }
58 |
59 | .form-row > * {
60 | flex: 2;
61 | }
62 |
--------------------------------------------------------------------------------
/src/components/settings/security/TwoFactor/SaveRecoveryCodeModal/index.js:
--------------------------------------------------------------------------------
1 | import { h } from 'preact';
2 |
3 | import Presentation from './presentation';
4 | import TestCode from './formTestCode';
5 | import { steps as ScopeModal, beforeDismiss as beforeDismissScopeModal } from '../../../../auth/ScopeModal';
6 | import store from '../../../../../helpers/store';
7 | import actions from '../../../../../actions/settings';
8 |
9 | export const steps = [
10 | ...ScopeModal('password'),
11 | {
12 | title: 'Save your recovery codes',
13 | mustSucceed: true,
14 | component({ onNextStep, onPreviousStep, onReset }) {
15 | return ;
16 | }
17 | },
18 | {
19 | title: 'Test your recovery codes',
20 | mustSucceed: true,
21 | component({ onNextStep, onPreviousStep }) {
22 | return ;
23 | }
24 | }
25 | ];
26 |
27 | export const beforeDismiss = () => {
28 | beforeDismissScopeModal();
29 | const { resetStoreAction } = actions(store);
30 |
31 | resetStoreAction(store.getState(), ['reset2FARecoveryCodes']);
32 | };
33 |
--------------------------------------------------------------------------------
/src/components/header/index.js:
--------------------------------------------------------------------------------
1 | import { h, Component } from 'preact';
2 | import { Link } from 'preact-router/match';
3 |
4 | import style from './style';
5 |
6 | export default class Header extends Component {
7 | render({ isLoggedIn, logout }) {
8 | return (
9 |
30 | );
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/tests/components/settings/security/TwoFactor/AddU2FModal/presentation.test.js:
--------------------------------------------------------------------------------
1 | import render from 'preact-render-to-string';
2 | import { deep } from 'preact-render-spy';
3 |
4 | import Presentation from '../../../../../../components/settings/security/TwoFactor/AddU2FModal/presentation';
5 |
6 | describe('AddU2FModal Presentation step', () => {
7 | test('regular display', () => {
8 | expect(render( )).toMatchSnapshot();
9 | });
10 | test('display with message', () => {
11 | expect(render( )).toMatchSnapshot();
12 | });
13 |
14 | test('submit and cancel', () => {
15 | const onSubmit = jest.fn();
16 | const onCancel = jest.fn();
17 | const context = deep( );
18 |
19 | const event = { preventDefault: () => undefined };
20 |
21 | context.find('form').simulate('submit', event);
22 | expect(onSubmit).toHaveBeenCalled();
23 | expect(onCancel).not.toHaveBeenCalled();
24 |
25 | context.find('form').simulate('reset', event);
26 | expect(onSubmit).toHaveBeenCalledTimes(1); // the submit event
27 | expect(onCancel).toHaveBeenCalled(); // only once
28 | });
29 | });
30 |
--------------------------------------------------------------------------------
/src/tests/components/settings/security/TwoFactor/SetupTOTPModal/presentation.test.js:
--------------------------------------------------------------------------------
1 | import render from 'preact-render-to-string';
2 | import { deep } from 'preact-render-spy';
3 |
4 | import Presentation from '../../../../../../components/settings/security/TwoFactor/SetupTOTPModal/presentation';
5 |
6 | describe('SetupTOTPModal Presentation step', () => {
7 | test('regular display', () => {
8 | expect(render( )).toMatchSnapshot();
9 | });
10 | test('display with message', () => {
11 | expect(render( )).toMatchSnapshot();
12 | });
13 |
14 | test('submit and cancel', () => {
15 | const onSubmit = jest.fn();
16 | const onCancel = jest.fn();
17 | const context = deep( );
18 |
19 | const event = { preventDefault: () => undefined };
20 |
21 | context.find('form').simulate('submit', event);
22 | expect(onSubmit).toHaveBeenCalled();
23 | expect(onCancel).not.toHaveBeenCalled();
24 |
25 | context.find('form').simulate('reset', event);
26 | expect(onSubmit).toHaveBeenCalledTimes(1); // the submit event
27 | expect(onCancel).toHaveBeenCalled(); // only once
28 | });
29 | });
30 |
--------------------------------------------------------------------------------
/src/tests/components/settings/security/TwoFactor/AddU2FModal/__snapshots__/formName.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`AddU2FModal SharedSecret step display 1`] = `
4 |
19 | `;
20 |
21 | exports[`AddU2FModal SharedSecret step display 2`] = `
22 |
37 | `;
38 |
--------------------------------------------------------------------------------
/src/tests/components/settings/security/TwoFactor/SetupTOTPModal/__snapshots__/presentation.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`SetupTOTPModal Presentation step display with message 1`] = `
4 |
13 | `;
14 |
15 | exports[`SetupTOTPModal Presentation step regular display 1`] = `
16 |
35 | `;
36 |
--------------------------------------------------------------------------------
/src/tests/components/auth/__snapshots__/formLogin2FA.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Testing FromLogin2FA with U2F 1`] = `
4 |
24 | `;
25 |
26 | exports[`Testing FromLogin2FA without U2F 1`] = `
27 |
41 | `;
42 |
--------------------------------------------------------------------------------
/src/tests/components/settings/security/TwoFactor/AddU2FModal/__snapshots__/presentation.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`AddU2FModal Presentation step display with message 1`] = `
4 |
13 | `;
14 |
15 | exports[`AddU2FModal Presentation step regular display 1`] = `
16 |
35 | `;
36 |
--------------------------------------------------------------------------------
/tasks/setupConfig.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const fs = require('fs');
4 | const path = require('path');
5 | const chalk = require('chalk');
6 | const dedent = require('dedent');
7 |
8 | const env = require('../env/config');
9 | const PATH_CONFIG = path.resolve('./src/config.js');
10 | const { CONFIG } = env.getConfig();
11 |
12 | fs.writeFileSync(PATH_CONFIG, `export default ${JSON.stringify(CONFIG, null, 4)};`);
13 | /**
14 | * Fuck you webpack
15 | * thx https://github.com/webpack/watchpack/issues/25#issuecomment-357483744
16 | */
17 | const now = Date.now() / 1000;
18 | const then = now - 11;
19 | fs.utimesSync(PATH_CONFIG, then, then);
20 | env.argv.debug && console.log(`${JSON.stringify(CONFIG, null, 2)}`);
21 |
22 | if (process.env.NODE_ENV !== 'dist' && process.env.NODE_ENV_MODE !== 'config') {
23 |
24 | if (!env.hasEnv() && !env.isWebClient()) {
25 | console.log();
26 | console.log(dedent`
27 | ${chalk.bgMagenta('⚠ No env.json detected')}
28 | ➙ Please check the wiki to create it
29 | `);
30 | console.log();
31 | }
32 |
33 | portfinder.getPortPromise().then((port) => {
34 | process.env.NODE_ENV_PORT = port;
35 | console.log(dedent`
36 | ${chalk.green('✓')} Generate configuration
37 | ➙ Api: ${chalk.bgYellow(chalk.black(CONFIG.apiUrl))}
38 | ➙ Sentry: ${chalk.yellow(process.env.NODE_ENV_SENTRY)}
39 | `);
40 | });
41 | }
42 |
--------------------------------------------------------------------------------
/src/components/ui/Modal/index.js:
--------------------------------------------------------------------------------
1 | import { h } from 'preact';
2 | import ReactModal from 'react-modal';
3 |
4 | import ModalStyles from './index.css';
5 |
6 | /**
7 | * Creates a new modal, using a predefined style.
8 | * @param {boolean} isOpen - Whether the modal is open or not.
9 | * @param {string} title - Title of the modal.
10 | * @param {Component} children
11 | * @param {Function} onRequestClose - called after the modal is closed.
12 | * @param {Function} onBeforeClose - called after the modal is closed.
13 | * @param {Function} onAfterOpen - called after the modal is opened.
14 | * @param {String} contentLabel- the content label. If not given, title is used.
15 | * @return {ReactModal} the modal components.
16 | * @function
17 | */
18 | const Modal = ({ isOpen, title, children, onRequestClose, onAfterOpen, contentLabel }) => {
19 | ReactModal.setAppElement('#app');
20 | return (
21 |
28 |
31 | {children}
32 |
33 | );
34 | };
35 |
36 | export default Modal;
37 | export Content from './Content';
38 | export Footer from './Footer';
39 | export Wrapper from './Wrapper';
40 |
--------------------------------------------------------------------------------
/src/components/auth/formLogin2FA.js:
--------------------------------------------------------------------------------
1 | import { h } from 'preact';
2 |
3 | import FormSignU2F from './formSignU2F';
4 |
5 | import style from './style.css';
6 |
7 | const formLogin2FA = ({ login2FA, twoFactorData: { isTOTP = true, U2F: U2FRequest } }) => {
8 | const model = {};
9 | return (
10 |
42 | );
43 | };
44 |
45 | export default formLogin2FA;
46 |
--------------------------------------------------------------------------------
/src/components/auth/formLogin.js:
--------------------------------------------------------------------------------
1 | import { h } from 'preact';
2 |
3 | const formLogin = ({ login }) => {
4 | const model = {
5 | username: 'dew1527087668398',
6 | password: 'test'
7 | };
8 |
9 | return (
10 |
47 | );
48 | };
49 |
50 | export default formLogin;
51 |
--------------------------------------------------------------------------------
/src/components/app.js:
--------------------------------------------------------------------------------
1 | import { h } from 'preact';
2 | import { Router, route } from 'preact-router';
3 | import { connect } from 'unistore/full/preact';
4 | import appProvider from 'frontend-commons/src/appProvider';
5 | import { isLoggedIn as isAuthenticated } from 'frontend-commons/src/user/model';
6 |
7 | import config from '../config';
8 | import Header from './header';
9 | import Home from '../routes/home';
10 | import Settings from '../routes/settings';
11 | import Dashboard from '../routes/dashboard';
12 | import authActions from '../actions/authentication';
13 | import NotificationStack from './ui/NotificationStack';
14 |
15 | appProvider.setConfig(config);
16 |
17 | export default connect(
18 | 'auth',
19 | authActions
20 | )(({ auth, loadAuthUserAction, logoutAction }) => {
21 | /**
22 | * Check if the user is authenticated or not.
23 | * Redirect to the login if he is not or load the config
24 | * @link {https://github.com/developit/preact-router#detecting-route-changes}
25 | */
26 | const onChangeRoute = async ({ previous, url }) => {
27 | if (!isAuthenticated()) {
28 | return route('/');
29 | }
30 |
31 | if (url !== previous && previous !== '/' && !auth.isLoggedIn) {
32 | await loadAuthUserAction(auth.user);
33 | }
34 | };
35 | return (
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | );
46 | });
47 |
--------------------------------------------------------------------------------
/src/components/settings/security/TwoFactor/AddU2FModal/index.js:
--------------------------------------------------------------------------------
1 | import { h } from 'preact';
2 |
3 | import Presentation from './presentation';
4 | import FormName from './formName';
5 | import RegisterKeyForm from './formRegisterKey';
6 | import { steps as RecoveryCodeSteps, beforeDismiss as beforeDismissRecoveryCodeModal } from '../SaveRecoveryCodeModal';
7 | import { steps as ScopeModal, beforeDismiss as beforeDismissScopeModal } from '../../../../auth/ScopeModal/index';
8 | import store from '../../../../../helpers/store';
9 | import actions from '../../../../../actions/settings';
10 |
11 | export const steps = [
12 | {
13 | title: 'Register new U2F Key',
14 | component({ onNextStep, onPreviousStep, message }) {
15 | return ;
16 | }
17 | },
18 | ...ScopeModal('password'),
19 | {
20 | title: 'Name your U2F Key',
21 | component({ onNextStep, onPreviousStep }) {
22 | return ;
23 | }
24 | },
25 | {
26 | title: 'Register new U2F Key',
27 | component({ onNextStep, onPreviousStep, forbidClosure, onReset }) {
28 | return (
29 |
35 | );
36 | }
37 | },
38 | ...RecoveryCodeSteps
39 | ];
40 |
41 | export const beforeDismiss = () => {
42 | beforeDismissScopeModal();
43 | beforeDismissRecoveryCodeModal();
44 | const { resetStoreAction } = actions(store);
45 | resetStoreAction(store.getState(), ['addU2FKey']);
46 | };
47 |
--------------------------------------------------------------------------------
/src/tests/helpers/u2f.test.js:
--------------------------------------------------------------------------------
1 | import appProvider from 'frontend-commons/src/appProvider';
2 | import { sign, register } from 'u2f-api';
3 |
4 | import { ERROR_CODE, getErrorMessage, registerU2F, signU2F } from '../../helpers/u2f';
5 |
6 | jest.mock('frontend-commons/src/appProvider');
7 | jest.mock('u2f-api');
8 |
9 | describe('u2f helper', () => {
10 | beforeAll(() =>
11 | appProvider.getConfig.mockImplementation(() => ({
12 | appId: 'https://example.com',
13 | timeout: 30
14 | })));
15 | afterAll(() => appProvider.getConfig.mockRestore());
16 |
17 | test('getErrorMessage', () => {
18 | for (const key in ERROR_CODE) {
19 | const code = ERROR_CODE[key];
20 | if (code) {
21 | expect(getErrorMessage(code, true)).toBeDefined();
22 | expect(getErrorMessage(code, false)).toBeDefined();
23 | } else {
24 | expect(() => {
25 | getErrorMessage(code);
26 | }).toThrow();
27 | }
28 | }
29 |
30 | expect(() => {
31 | getErrorMessage('I do not exist');
32 | }).toThrow();
33 | });
34 |
35 | test('registerU2F', async () => {
36 | register.mockImplementation(() => ({}));
37 |
38 | await registerU2F({
39 | RegisteredKeys: [{ Version: 'U2F', KeyHandle: 'flemme' }],
40 | Challenge: 'challenge',
41 | Versions: ['U2F_V2']
42 | });
43 |
44 | expect(register).toBeCalled();
45 |
46 | register.mockRestore();
47 | });
48 | test('signU2F', async () => {
49 | sign.mockImplementation(() => ({}));
50 |
51 | await signU2F({
52 | RegisteredKeys: [{ Version: 'U2F', KeyHandle: 'flemme' }],
53 | Challenge: 'challenge'
54 | });
55 |
56 | expect(sign).toBeCalled();
57 |
58 | sign.mockRestore();
59 | });
60 | });
61 |
--------------------------------------------------------------------------------
/src/components/settings/security/TwoFactor/SetupTOTPModal/index.js:
--------------------------------------------------------------------------------
1 | import { h } from 'preact';
2 |
3 | import Presentation from './presentation';
4 | import SharedSecret from './sharedSecret';
5 | import { steps as RecoveryCodeSteps, beforeDismiss as beforeDismissRecoveryCodeModal } from '../SaveRecoveryCodeModal';
6 | import { steps as ScopeModal, beforeDismiss as beforeDismissScopeModal } from '../../../../auth/ScopeModal/index';
7 | import store from '../../../../../helpers/store';
8 | import actions from '../../../../../actions/settings';
9 | import ConfirmCode from './confirmCode';
10 |
11 | export const steps = [
12 | {
13 | title: 'Set Up Two Factor Authentication',
14 | component({ onNextStep, onPreviousStep, message }) {
15 | return ;
16 | }
17 | },
18 | ...ScopeModal('password'),
19 | {
20 | title: 'Set Up Two Factor Authentication',
21 | component({ onNextStep, onPreviousStep }) {
22 | return ;
23 | }
24 | },
25 | {
26 | title: 'Confirm your new method',
27 | component({ onNextStep, onPreviousStep, onReset, forbidClosure }) {
28 | return (
29 |
35 | );
36 | }
37 | },
38 | ...RecoveryCodeSteps
39 | ];
40 |
41 | export const beforeDismiss = (success = false, reset = false) => {
42 | beforeDismissScopeModal();
43 | beforeDismissRecoveryCodeModal();
44 | if (success || !reset) {
45 | const { resetStoreAction } = actions(store);
46 | resetStoreAction(store.getState(), ['setupTOTP']);
47 | }
48 | };
49 |
--------------------------------------------------------------------------------
/src/tests/components/settings/security/TwoFactor/AddU2FModal/formName.test.js:
--------------------------------------------------------------------------------
1 | import render from 'preact-render-to-string';
2 | import { deep } from 'preact-render-spy';
3 |
4 | import { FormName } from '../../../../../../components/settings/security/TwoFactor/AddU2FModal/formName';
5 |
6 | describe('AddU2FModal SharedSecret step', () => {
7 | test('display', () => {
8 | expect(render( )).toMatchSnapshot();
9 | expect(
10 | render( )
11 | ).toMatchSnapshot();
12 | });
13 |
14 | test('input event', () => {
15 | const context = deep( );
16 | context.find('input').simulate('input', { target: { value: 'Something' } });
17 | });
18 |
19 | test('submit and cancel', () => {
20 | const onSubmit = jest.fn();
21 | const onCancel = jest.fn();
22 | const addU2FKeyLabelAction = jest.fn();
23 | const context = deep(
24 |
32 | );
33 |
34 | const event = { preventDefault: () => undefined };
35 |
36 | context.find('form').simulate('submit', event);
37 | expect(onSubmit).toHaveBeenCalled();
38 | expect(addU2FKeyLabelAction).toHaveBeenLastCalledWith('Test for key');
39 | expect(onCancel).not.toHaveBeenCalled();
40 |
41 | context.find('form').simulate('reset', event);
42 | expect(onSubmit).toHaveBeenCalledTimes(1); // the submit event
43 | expect(addU2FKeyLabelAction).toHaveBeenCalledTimes(1);
44 | expect(onCancel).toHaveBeenCalled(); // only once
45 | });
46 | });
47 |
--------------------------------------------------------------------------------
/src/routes/settings/index.js:
--------------------------------------------------------------------------------
1 | import { h } from 'preact';
2 | import { Link } from 'preact-router';
3 | import { connect } from 'unistore/full/preact';
4 |
5 | import Security from '../../components/settings/security';
6 |
7 | import styles from './index.css';
8 |
9 | /**
10 | * Renders the content of the selected setting (using the URI).
11 | * @param {String} setting - the route of the setting to render
12 | * @param {Object} user
13 | * @returns {Component}
14 | */
15 | function renderContent(setting, user) {
16 | if (setting === 'security') {
17 | return ;
18 | }
19 | return (
20 |
21 | Paramètres pour {user.Username}
22 |
23 | );
24 | }
25 |
26 | export default connect(['auth', 'config'])(({ side, config }) => {
27 | if (!config) {
28 | return null;
29 | }
30 | const {
31 | settings: { user }
32 | } = config;
33 | console.log('SETTINGS', user);
34 |
35 | if (!Object.keys(user).length) {
36 | return null;
37 | }
38 |
39 | // @TODO fix padding
40 | return (
41 |
42 |
43 |
44 |
45 |
46 | {' '}
47 | Home{' '}
48 |
49 |
50 |
51 |
52 | {' '}
53 | Security{' '}
54 |
55 |
56 |
57 |
58 |
59 | {renderContent(side, user)}
60 |
61 |
62 | );
63 | });
64 |
--------------------------------------------------------------------------------
/src/tests/components/settings/security/TwoFactor/U2FKeyList.test.js:
--------------------------------------------------------------------------------
1 | import render from 'preact-render-to-string';
2 | import { deep } from 'preact-render-spy';
3 |
4 | import { U2FKeyList } from '../../../../../components/settings/security/TwoFactor/U2FKeyList/U2FKeyList';
5 |
6 | jest.mock('../../../../../actions/settings');
7 |
8 | import { deepProvider, renderProvided, shallowProvider } from '../../../../testsHelpers/storeTools';
9 | import store from '../../../../../helpers/store';
10 |
11 | describe('U2FKeyList test', () => {
12 | test('with no key', () => {
13 | expect(render( )).toMatchSnapshot();
14 | });
15 |
16 | test('with keys', () => {
17 | const keys = [
18 | { KeyHandle: '1', Compromised: false, Label: 'First key' },
19 | { KeyHandle: '2', Compromised: false, Label: 'Second key' },
20 | { KeyHandle: '3', Compromised: true, Label: 'Compromised key' },
21 | { KeyHandle: '4', Compromised: false, Label: 'Last key' }
22 | ];
23 | expect(render( )).toMatchSnapshot();
24 | });
25 |
26 | test('opening modal', () => {
27 | const keys = [
28 | { KeyHandle: '1', Compromised: false, Label: 'First key' },
29 | { KeyHandle: '2', Compromised: false, Label: 'Second key' },
30 | { KeyHandle: '3', Compromised: true, Label: 'Compromised key' },
31 | { KeyHandle: '4', Compromised: false, Label: 'Last key' }
32 | ];
33 | const context = deep( );
34 | expect(context.state('confirmDeleteModal')).toBeFalsy();
35 | context
36 | .find('a')
37 | .first()
38 | .simulate('click');
39 | expect(context).toMatchSnapshot();
40 | expect(context.state('confirmDeleteModal')).toMatchObject(
41 | expect.objectContaining({
42 | KeyHandle: expect.any(String),
43 | Label: expect.any(String)
44 | })
45 | );
46 | });
47 | });
48 |
--------------------------------------------------------------------------------
/src/tests/components/settings/security/TwoFactor/__snapshots__/U2FKeyList.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`U2FKeyList test opening modal 1`] = `
4 | preact-render-spy (1 nodes)
5 | -------
6 |
49 |
50 | `;
51 |
52 | exports[`U2FKeyList test with keys 1`] = `
53 |
54 |
55 |
56 | Delete
57 |
58 |
59 |
60 | Delete
61 |
62 |
63 |
64 | Compromised
65 | Delete
66 |
67 |
68 |
69 | Delete
70 |
71 |
72 | `;
73 |
74 | exports[`U2FKeyList test with no key 1`] = ``;
75 |
--------------------------------------------------------------------------------
/src/components/settings/security/TwoFactor/AddU2FModal/presentation.js:
--------------------------------------------------------------------------------
1 | import { h } from 'preact';
2 |
3 | import { Content as ModalContent, Footer as ModalFooter, Wrapper as ModalWrapper } from '../../../../ui/Modal';
4 | import Link from '../../../../ui/Link';
5 |
6 | /**
7 | * Shows information about the U2F Key usage.
8 | * @param {Object} props
9 | * @param {Function} props.onSubmit - triggers the next step.
10 | * @param {Function} props.onCancel - triggers the previous step.
11 | * @return {*}
12 | */
13 | const Presentation = ({ onSubmit, onCancel, message }) => (
14 | {
16 | e.preventDefault();
17 | onSubmit();
18 | }}
19 | onReset={(e) => {
20 | e.preventDefault();
21 | onCancel();
22 | }}
23 | >
24 | {message ? (
25 |
26 | {message}
27 |
28 | ) : (
29 |
30 | This wizard will add a new security key to your Proton Account.
31 |
32 | Please note that you will not be able to access your account if you loose your U2F device and your
33 | recovery codes. We recommend setting up a second 2FA method as a backup.
34 |
35 |
36 |
37 | If you have never used 2FA before, we strongly recommend you reading our 2FA Guide first.
38 |
39 |
40 |
41 | READ U2F GUIDE
42 |
43 |
44 | )}
45 |
46 |
47 | Back
48 |
49 |
50 | Next
51 |
52 |
53 |
54 | );
55 |
56 | export default Presentation;
57 |
--------------------------------------------------------------------------------
/src/components/settings/security/TwoFactor/SetupTOTPModal/presentation.js:
--------------------------------------------------------------------------------
1 | import { h } from 'preact';
2 |
3 | import Link from '../../../../ui/Link';
4 | import { Content as ModalContent, Footer as ModalFooter, Wrapper as ModalWrapper } from '../../../../ui/Modal';
5 |
6 | /**
7 | * Shows information about the U2F Key usage.
8 | * @param {Object} props
9 | * @param {Function} props.onSubmit - triggers the next step.
10 | * @param {Function} props.onCancel - triggers the previous step.
11 | * @return {*}
12 | */
13 | const Presentation = ({ onSubmit, onCancel, message }) => (
14 | {
16 | e.preventDefault();
17 | onSubmit();
18 | }}
19 | onReset={(e) => {
20 | e.preventDefault();
21 | onCancel();
22 | }}
23 | >
24 | {message ? (
25 |
26 | {message}
27 |
28 | ) : (
29 |
30 |
31 | This wizard will enable Two Factor Authentication (2FA) on your ProtonMail account. 2FA will make
32 | your ProtonMail account more secure so we recommend enabling it.{' '}
33 |
34 |
35 |
36 | If you have never used 2FA before, we strongly recommend you reading our 2FA Guide first.
37 |
38 |
39 |
40 |
41 | 2FA GUIDE
42 |
43 |
44 |
45 | )}
46 |
47 |
48 | Cancel
49 |
50 |
51 | Next
52 |
53 |
54 |
55 | );
56 |
57 | export default Presentation;
58 |
--------------------------------------------------------------------------------
/src/tests/components/settings/security/TwoFactor/index.test.js:
--------------------------------------------------------------------------------
1 | import { isSupported } from 'u2f-api';
2 |
3 | import TwoFactorSettings from '../../../../../components/settings/security/TwoFactor';
4 | import { renderProvided } from '../../../../testsHelpers/storeTools';
5 |
6 | describe('TwoFactorSettings', () => {
7 | test('2FA disabled', () => {
8 | expect(renderProvided( )).toMatchSnapshot();
9 | });
10 |
11 | test('TOTP only enable', () => {
12 | expect(renderProvided( )).toMatchSnapshot();
13 | });
14 |
15 | test('U2F only enable', () => {
16 | const keys = [
17 | { KeyHandle: '1', Compromised: false, Label: 'First key' },
18 | { KeyHandle: '2', Compromised: false, Label: 'Second key' },
19 | { KeyHandle: '3', Compromised: true, Label: 'Compromised key' },
20 | { KeyHandle: '4', Compromised: false, Label: 'Last key' }
21 | ];
22 |
23 | expect(renderProvided( )).toMatchSnapshot();
24 | });
25 |
26 | test('Everything enable', () => {
27 | const keys = [
28 | { KeyHandle: '1', Compromised: false, Label: 'First key' },
29 | { KeyHandle: '2', Compromised: false, Label: 'Second key' },
30 | { KeyHandle: '3', Compromised: true, Label: 'Compromised key' },
31 | { KeyHandle: '4', Compromised: false, Label: 'Last key' }
32 | ];
33 |
34 | expect(renderProvided( )).toMatchSnapshot();
35 | });
36 |
37 | test('U2F not supported', () => {
38 | const keys = [
39 | { KeyHandle: '1', Compromised: false, Label: 'First key' },
40 | { KeyHandle: '2', Compromised: false, Label: 'Second key' }
41 | ];
42 |
43 | isSupported.mockReturnValueOnce(false);
44 |
45 | expect(renderProvided( )).toMatchSnapshot();
46 | });
47 | });
48 |
--------------------------------------------------------------------------------
/src/helpers/stateFormatter.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Format the state and extend it as it's not recursive with unistore
3 | * @param {Object} state
4 | * @param {String} key
5 | * @param {Object} value
6 | * @return {Object} new state
7 | * @function
8 | */
9 | export const toState = (state, key, value) => ({ [key]: { ...state[key], ...value } });
10 |
11 | /**
12 | * Format the state and extends using the dot notation.
13 | * @param {Object} state - the state to update
14 | * @param {string|string[]} extendedKey - the key to update in the state. It can be either a string, either a list of string.
15 | * If it's a string, it corresponds to a path, the `.` separates the keys. If it's an array, each element is a key.
16 | * @param {Object} value - the updated value.
17 | * @returns {Object} new state.
18 | * @example
19 | * > extended(
20 | * {
21 | * a: 1,
22 | * b: {
23 | * c: {
24 | * d: 2,
25 | * e: 3
26 | * },
27 | * f: {
28 | * g: 4
29 | * }
30 | * }
31 | * },
32 | * 'b.c',
33 | * { d: 42, f: 25 }
34 | * );
35 | * // returns
36 | * {
37 | * // a removed, because root keys are handled by unistore
38 | * b: {
39 | * c: {
40 | * d: 42, // updated
41 | * f: 25, // new
42 | * e: 3 // unchanged
43 | * },
44 | * f: { // f is not removed, because subkeys are not handled by unistore
45 | * g: 4
46 | * }
47 | * }
48 | * }
49 | *
50 | * @example
51 | * // beware!
52 | * > extended({...sameAsBefore }, ['b.c'], { d: 42, f: 25 })
53 | * { 'b.c': { d: 42, f: 25 } } // the new key 'b.c' is created
54 | * @function
55 | */
56 | export const extended = (state, extendedKey, value) => {
57 | if (!Array.isArray(extendedKey)) {
58 | return extended(state, extendedKey.split('.'), value);
59 | }
60 | if (!extendedKey.length) {
61 | return value;
62 | }
63 |
64 | const [firstKey, ...rest] = extendedKey;
65 | return toState(state, firstKey, extended(state[firstKey] || {}, rest, value));
66 | };
67 |
--------------------------------------------------------------------------------
/src/components/ui/NotificationStack/index.js:
--------------------------------------------------------------------------------
1 | import { h, Component } from 'preact';
2 |
3 | import { onInput } from '../../../helpers/notification';
4 | import styles from './index.css';
5 |
6 | const EXPIRATION_TIME = 5000;
7 | const MAX_STACK_SIZE = 5;
8 |
9 | /**
10 | * Displays all the current notifications.
11 | */
12 | export default class NotificationStack extends Component {
13 | constructor(props) {
14 | super(props);
15 | this.state = {
16 | stack: [],
17 | popping: false
18 | };
19 | onInput((notification) => {
20 | const removed = this.state.stack[MAX_STACK_SIZE - 1];
21 | const newNotification = {
22 | notification,
23 | timeoutId: this.popNotification()
24 | };
25 | this.setState({
26 | stack: [newNotification, ...this.state.stack.slice(0, MAX_STACK_SIZE - 1)]
27 | });
28 | if (removed) {
29 | clearTimeout(removed.timeoutId);
30 | }
31 | });
32 | }
33 |
34 | /**
35 | * Deletes the associated notification.
36 | * @return {number} the setTimeout id.
37 | */
38 | popNotification() {
39 | const id = setTimeout(() => {
40 | const stack = this.state.stack.filter(({ timeoutId }) => timeoutId !== id);
41 | this.setState({ stack });
42 | clearTimeout(id);
43 | }, EXPIRATION_TIME);
44 | return id;
45 | }
46 |
47 | render() {
48 | const notifications = this.state.stack;
49 | if (notifications && notifications.length) {
50 | return (
51 | {notifications.map(NotificationStack.renderNotification)}
52 | );
53 | }
54 | return null;
55 | }
56 |
57 | static renderNotification({
58 | notification: {
59 | type,
60 | data: { message, opt }
61 | }
62 | }) {
63 | const classes = [styles.notification];
64 | if (styles[type]) {
65 | classes.push(styles[type]);
66 | }
67 | return (
68 |
69 | {message}
70 |
71 | );
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/components/settings/security/TwoFactor/AddU2FModal/formName.js:
--------------------------------------------------------------------------------
1 | import { h } from 'preact';
2 | import { connect } from 'unistore/full/preact';
3 |
4 | import { Content as ModalContent, Footer as ModalFooter, Wrapper as ModalWrapper } from '../../../../ui/Modal';
5 | import settingsActions from '../../../../../actions/settings';
6 |
7 | import styles from './index.css';
8 |
9 | /**
10 | * Label form for the U2F key.
11 | */
12 | export const FormName = ({ onSubmit, onCancel, addU2FKeyLabelAction, settings: { addU2FKey: addU2FKeyStore } }) => {
13 | const model = { label: addU2FKeyStore.response ? addU2FKeyStore.response.label : '' };
14 | return (
15 | {
17 | e.preventDefault();
18 | addU2FKeyLabelAction(model.label);
19 | onSubmit();
20 | }}
21 | onReset={(e) => {
22 | e.preventDefault();
23 | onCancel();
24 | }}
25 | >
26 |
27 |
28 |
29 | Name
30 |
31 |
32 | {
34 | model.label = value;
35 | }}
36 | value={model.label}
37 | type="name"
38 | id="inputName"
39 | required
40 | placeholder="Name"
41 | className={styles.nameTextInput}
42 | ref={(input) => input && input.focus()}
43 | />
44 |
45 |
46 |
47 |
48 |
49 | Back
50 |
51 |
52 | Next
53 |
54 |
55 |
56 | );
57 | };
58 |
59 | export default connect(
60 | 'settings',
61 | settingsActions
62 | )(FormName);
63 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # account [](https://circleci.com/gh/ProtonMail/account)
2 |
3 |
4 | ## Install
5 |
6 | - `$ npm i`
7 |
8 | > You need node > 8 and npm >= 5
9 |
10 | ## Dev
11 |
12 | - `npm run dev`
13 |
14 | > You must have the lib **frontend-commons**. As it's not available yet, install it via npm link ;)
15 |
16 | > You must also set up the file `src/config.js`.
17 | ### U2F
18 |
19 | First add in `src/config.js` the `u2f` object, with value. This value should also be compatible with the server side:
20 | ```json
21 | {
22 | "appId": "the route to the app id",
23 | "timeout": 1 // timeout value
24 | }
25 | ```
26 |
27 | #### Testing
28 |
29 | Testing U2F can be complicated: the server must accept a U2F binding on the localhost domain.
30 | For that, the domain must use the app id https://localhost.
31 |
32 | In order to test U2F without having a physical device, the chrome extension
33 | [virtual-u2f](https://github.com/ProtonMail/virtual-u2f) can be used. This
34 | extension is only suitable for testing purposes. It is not possible to combine
35 | this extension with regular U2F token.
36 |
37 | Mac users can also try the [SoftU2F](https://github.com/github/SoftU2F) project (maintained by github, more secure than virtual-u2f).
38 | Linux users can try the equivalent [Rust U2F](https://github.com/danstiner/rust-u2f) project (unstable).
39 |
40 | #### App ID
41 |
42 | The app id specification are [very strict](https://fidoalliance.org/specs/fido-u2f-v1.2-ps-20170411/fido-appid-and-facets-v1.2-ps-20170411.html#the-appid-and-facetid-assertions). Some rules are:
43 |
44 | * The AppID must be served under HTTPS;
45 | * The AppID can be an URL (eg https://account.proton.me) or a path to a json file (eg https://account.proton.me/app-id.json, with the **MIME Content-Type of `application/fido.trusted-apps+json`**);
46 | * An application using the AppID can be under the AppID domain, but the opposite if not possible:
47 |
48 | If the AppId is https://account.proton.me/app-id.json:
49 | * a service under https://account.proton.me will be able to use the AppID
50 | * a service under https://service.account.proton.me will be able to use the AppID
51 | * a service under https://pay.proton.me will be **not** able to use the AppID
52 |
53 | *Deleting the AppID or moving it to another route will deactivate every registered key*. It is possible to use a 3XX redirection (see 9. from the doc for more info).
54 |
55 |
--------------------------------------------------------------------------------
/src/tests/components/settings/security/TwoFactor/SaveRecoveryCodeModal/__snapshots__/presentation.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`SaveRecoveryCodeModal presentation display 1`] = `
4 |
57 | `;
58 |
59 | exports[`SaveRecoveryCodeModal presentation loading display 1`] = `
60 |
72 | `;
73 |
--------------------------------------------------------------------------------
/src/components/auth/formSignU2F.js:
--------------------------------------------------------------------------------
1 | import { h, Component } from 'preact';
2 | import { connect } from 'unistore/full/preact';
3 | import { isSupported } from 'u2f-api';
4 |
5 | import authActions from './../../actions/authentication';
6 | import { ERROR_CODE, getErrorMessage } from '../../helpers/u2f';
7 |
8 | /**
9 | * Form for the login 2FA action.
10 | */
11 | export class FormSignU2F extends Component {
12 | sendSignRequest() {
13 | const { success, U2FResponse: { metaData: { code } = {} } = {} } = this.props.auth.twoFactorResponse;
14 |
15 | if (!success && code) {
16 | if (code === ERROR_CODE.TIMEOUT) {
17 | // we need an updated auth/info
18 | // the timeout is 1 minute on the client side, it's better
19 | // to redo the whole process because the challenge expire after 2 minutes.
20 | // funny thing, firefox uses the errorno OTHER_ERROR (1)
21 | return this.props.abortLoginAction();
22 | }
23 | }
24 |
25 | this.props.loginU2FAction();
26 | }
27 |
28 | componentDidMount() {
29 | this.sendSignRequest();
30 | }
31 |
32 | renderCodes(code) {
33 | if (code) {
34 | return (
35 |
36 | {getErrorMessage(code)}.
37 | this.sendSignRequest()} type="button">
38 | Retry
39 |
40 |
41 | );
42 | }
43 | }
44 |
45 | render() {
46 | if (!isSupported()) {
47 | return (
48 |
49 |
Your browser is not supported, please use another 2FA method instead
50 |
51 | );
52 | }
53 |
54 | const { success, U2FResponse = {} } = this.props.auth.twoFactorResponse;
55 |
56 | if (success && !U2FResponse.metaData) {
57 | return (
58 |
61 | );
62 | }
63 |
64 | const { metaData: { code } = {} } = U2FResponse;
65 |
66 | return (
67 |
68 |
Activate your security key...
69 | {!success && this.renderCodes(code)}
70 |
71 | );
72 | }
73 | }
74 |
75 | export default connect(
76 | 'auth',
77 | authActions
78 | )(FormSignU2F);
79 |
--------------------------------------------------------------------------------
/src/components/ui/ConfirmModal.js:
--------------------------------------------------------------------------------
1 | import { h, Component } from 'preact';
2 | import _ from 'lodash';
3 |
4 | import { Content, Footer, Wrapper } from './Modal';
5 | import SteppedModal from './SteppedModal';
6 | import { steps as scopeModalSteps, beforeDismiss as beforeDismissScopeModal } from '../auth/ScopeModal';
7 |
8 | export default class ConfirmModal extends Component {
9 | // for some reason, using stateless components produces a Build error...
10 | render() {
11 | const {
12 | children,
13 | title,
14 | isOpen,
15 | onConfirm,
16 | onAfterClose,
17 | scope = _.noop,
18 | onCancel = _.noop,
19 | cancelText = 'Cancel',
20 | confirmText = 'Confirm'
21 | } = this.props;
22 | const steps = [
23 | {
24 | title,
25 | component: ({ onNextStep, onPreviousStep }) => (
26 | {
28 | e.preventDefault();
29 | onNextStep();
30 | }}
31 | onReset={(e) => {
32 | e.preventDefault();
33 | if (onCancel) {
34 | onPreviousStep();
35 | }
36 | onAfterClose();
37 | }}
38 | >
39 | {children}
40 |
41 |
42 | {cancelText}
43 |
44 |
45 | {confirmText}
46 |
47 |
48 |
49 | )
50 | }
51 | ];
52 |
53 | if (scope) {
54 | steps.push(...scopeModalSteps(scope));
55 | }
56 |
57 | const beforeDismiss = async (success) => {
58 | if (success) {
59 | await onConfirm();
60 | }
61 | if (scope) {
62 | beforeDismissScopeModal();
63 | }
64 | };
65 |
66 | return (
67 |
68 | );
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/env/config.constants.js:
--------------------------------------------------------------------------------
1 | const ENV = (() => {
2 | try {
3 | return require('./env.json');
4 | } catch (e) {
5 | return {};
6 | }
7 | })();
8 |
9 | const AUTOPREFIXER_CONFIG = {
10 | browsers: ['last 2 versions', 'iOS >= 8', 'Safari >= 8']
11 | };
12 |
13 | const STATS_ID = {
14 | a: {
15 | siteId: 5, // the id of the global (total) piwik site
16 | abSiteId: 8 // the id of the piwik site that is configured to only track requests that touch this FE version
17 | },
18 | b: {
19 | siteId: 5, // the id of the global (total) piwik site
20 | abSiteId: 9 // the id of the piwik site that is configured to only track requests that touch this FE version
21 | }
22 | };
23 |
24 | const API_TARGETS = {
25 | prod: 'https://protonmail.blue/api',
26 | local: 'https://protonmail.dev/api',
27 | localhost: 'https://localhost/api',
28 | host: 'https://mail.protonmail.host/api',
29 | vagrant: 'https://172.28.128.3/api',
30 | build: '/api'
31 | };
32 |
33 | Object.keys(ENV).forEach((key) => {
34 | if (ENV[key].api) {
35 | API_TARGETS[key] = ENV[key].api;
36 | }
37 | });
38 |
39 | const SENTRY_CONFIG = Object.keys(ENV)
40 | .reduce((acc, env) => (acc[env] = ENV[env].sentry, acc), {});
41 |
42 | const PROD_STAT_MACHINE = {
43 | isEnabled: false,
44 | statsHost: 'stats.protonmail.ch',
45 | domains: ['*.protonmail.com', '*.mail.protonmail.com'],
46 | cookieDomain: '*.protonmail.com'
47 | };
48 |
49 | const HOST_STAT_MACHINE = {
50 | isEnabled: false,
51 | statsHost: 'stats.protonmail.host',
52 | domains: ['*.protonmail.host', '*.mail.protonmail.host'],
53 | cookieDomain: '*.protonmail.host'
54 | };
55 |
56 | const NO_STAT_MACHINE = { isEnabled: false };
57 |
58 | const STATS_CONFIG = {
59 | beta: NO_STAT_MACHINE,
60 | prod: NO_STAT_MACHINE,
61 | dev: NO_STAT_MACHINE,
62 | host: NO_STAT_MACHINE
63 | };
64 |
65 | /**
66 | * The configuration for U2F.
67 | * FIXME Do not modify this value once used in production! See README.md, section app_id for more information.
68 | * @type {{appId: string, timeout: number}}
69 | */
70 | const U2F_CONFIG = {
71 | appId: 'https://account.protonmail.red/app-id.json',
72 | timeout: 60
73 | };
74 |
75 | const TOR_URL = 'https://protonirockerxow.onion/';
76 |
77 | module.exports = {
78 | TOR_URL,
79 | AUTOPREFIXER_CONFIG,
80 | STATS_ID,
81 | API_TARGETS,
82 | PROD_STAT_MACHINE,
83 | HOST_STAT_MACHINE,
84 | NO_STAT_MACHINE,
85 | STATS_CONFIG,
86 | SENTRY_CONFIG,
87 | U2F_CONFIG
88 | };
89 |
--------------------------------------------------------------------------------
/src/components/settings/security/TwoFactor/U2FKeyList/U2FKeyList.js:
--------------------------------------------------------------------------------
1 | import { h, Component } from 'preact';
2 | import { connect } from 'unistore/full/preact';
3 |
4 | import settingsActions from '../../../../../actions/settings';
5 | import ConfirmModal from '../../../../ui/ConfirmModal';
6 | import TextButton from '../../../../ui/TextButton';
7 |
8 | import style from './style.css';
9 |
10 | export class U2FKeyList extends Component {
11 | /**
12 | * renders an U2F Key.
13 | * @param {Object} u2fKey - the U2F Key to render.
14 | * @param {Int} u2fKey.Compromised - whether the key is compromised or not.
15 | * @param {String} u2fKey.KeyHandle - The key handle of the current key.
16 | * @param {String} u2fKey.Label - The label of the current key.
17 | * @return {Component}
18 | */
19 | renderU2FKey(u2fKey) {
20 | const headerClasses = [style.listElementHeader];
21 | if (u2fKey.Compromised) {
22 | headerClasses.push(style.listElementHeaderCompromised);
23 | }
24 |
25 | return (
26 |
27 | {u2fKey.Label}
28 | {!!u2fKey.Compromised && Compromised
}
29 |
31 | this.setState({
32 | confirmDeleteModal: u2fKey
33 | })
34 | }
35 | >
36 | Delete
37 |
38 |
39 | );
40 | }
41 |
42 | render() {
43 | const confirmDeleteModal = this.state.confirmDeleteModal;
44 |
45 | const closeModal = () => {
46 | this.setState({ confirmDeleteModal: '' });
47 | };
48 | return (
49 |
65 | );
66 | }
67 | }
68 |
69 | export default connect(
70 | 'settings',
71 | settingsActions
72 | )(U2FKeyList);
73 |
--------------------------------------------------------------------------------
/src/tests/components/settings/security/TwoFactor/SaveRecoveryCodeModal/formTestCode.test.js:
--------------------------------------------------------------------------------
1 | import { deep } from 'preact-render-spy';
2 | import render from 'preact-render-to-string';
3 |
4 | import { FormTestCode } from '../../../../../../components/settings/security/TwoFactor/SaveRecoveryCodeModal/formTestCode';
5 |
6 | const renderFormTestCode = (props = {}, { code = '', result = undefined } = {}) => {
7 | const response = code ? { code } : undefined;
8 | return (
9 |
15 | );
16 | };
17 |
18 | describe('test for SaveRecoveryCodeModal SaveCode step', () => {
19 | test('initial display', () => {
20 | expect(render(renderFormTestCode())).toMatchSnapshot();
21 | });
22 |
23 | test('ongoing display', () => {
24 | expect(render(renderFormTestCode({}, { code: '945dc' }))).toMatchSnapshot();
25 | });
26 |
27 | test('success display', () => {
28 | expect(render(renderFormTestCode({}, { result: true }))).toMatchSnapshot();
29 | });
30 |
31 | test('failure display', () => {
32 | expect(render(renderFormTestCode({}, { result: false, code: '945dc945dc' }))).toMatchSnapshot();
33 | });
34 |
35 | test('on input test', () => {
36 | const reset2FARecoveryCodesCheckNewCodeAction = jest.fn();
37 | const context = deep(
38 | renderFormTestCode(
39 | { reset2FARecoveryCodesCheckNewCodeAction },
40 | {
41 | result: false,
42 | code: '945dc'
43 | }
44 | )
45 | );
46 |
47 | context.find('input').simulate('input', { target: { value: '945dcc' } });
48 | expect(reset2FARecoveryCodesCheckNewCodeAction).not.toHaveBeenCalled();
49 |
50 | context.find('input').simulate('input', { target: { value: '12345678' } });
51 | expect(reset2FARecoveryCodesCheckNewCodeAction).toHaveBeenCalledWith('12345678');
52 | });
53 |
54 | test('submit and cancel', () => {
55 | const onSubmit = jest.fn();
56 | const onCancel = jest.fn();
57 | const context = deep(
58 | renderFormTestCode(
59 | { onSubmit, onCancel },
60 | {
61 | result: false,
62 | code: '945dc'
63 | }
64 | )
65 | );
66 |
67 | const event = { preventDefault: () => undefined };
68 |
69 | context.find('form').simulate('submit', event);
70 | expect(onSubmit).toHaveBeenCalled();
71 | expect(onCancel).not.toHaveBeenCalled();
72 |
73 | context.find('form').simulate('reset', event);
74 | expect(onSubmit).toHaveBeenCalledTimes(1); // the submit event
75 | expect(onCancel).toHaveBeenCalled(); // only once
76 | });
77 | });
78 |
--------------------------------------------------------------------------------
/src/actions/scope.js:
--------------------------------------------------------------------------------
1 | import { authInfo } from 'frontend-commons/src/crypto/srp';
2 |
3 | import { signU2F } from '../helpers/u2f';
4 | import toActions from '../helpers/toActions';
5 | import { toState, extended } from '../helpers/stateFormatter';
6 |
7 | /**
8 | * @link{https://github.com/developit/unistore#usage}
9 | */
10 | export default (store) => {
11 | /**
12 | * initializate the unscope process.
13 | * @param {Object} state
14 | * @returns {Promise}
15 | */
16 | async function unscopeInit(state) {
17 | if (state.scope.used) {
18 | return;
19 | }
20 | if (!state.scope.response) {
21 | const authData = await authInfo(state.auth.Name);
22 | store.setState(toState(state, 'scope', { response: authData, creds: {} }));
23 | }
24 | }
25 |
26 | /**
27 | * Sets the password (and the twoFactorCode, if given)
28 | * @param {Object} state
29 | * @param {Object} opt
30 | * @param {string} opt.password - the password
31 | * @param {?string} twoFactorCode - the 2FA code (TOTP or recovery code).
32 | */
33 | async function unscopePassword(state, { password, twoFactorCode = undefined }) {
34 | const creds = { password };
35 | if (twoFactorCode !== undefined) {
36 | creds.twoFactorCode = twoFactorCode;
37 | }
38 |
39 | store.setState(extended(state, 'scope.creds', creds));
40 | }
41 |
42 | /**
43 | * sends and store a sign request to the U2F API.
44 | * @param {Object} state
45 | * @returns {Promise}
46 | */
47 | async function unscopeU2F(state) {
48 | store.setState(toState(state, 'scope', { U2FRequest: { status: 'pending' } }));
49 | try {
50 | const response = await signU2F(state.scope.response['2FA'].U2F);
51 | store.setState(extended(state, 'scope.creds', { U2F: response }));
52 | } catch (error) {
53 | const { metaData: { code } = {} } = error;
54 | if (!code) {
55 | throw error;
56 | }
57 | store.setState(toState(store.getState(), 'scope', { U2FRequest: { status: 'failure', error } }));
58 | }
59 | }
60 |
61 | /**
62 | * reset the two factor data (code and U2F).
63 | * @param {Object} state
64 | */
65 | async function unscopeResetTwoFactor(state) {
66 | return store.setState(
67 | toState(state, 'scope', {
68 | U2FRequest: {},
69 | creds: {
70 | password: state.scope.creds.password
71 | }
72 | })
73 | );
74 | }
75 |
76 | /**
77 | * reset the state for the scope
78 | * @param {Object} state
79 | */
80 | async function resetScopeState(state) {
81 | store.setState({ scope: {} });
82 | }
83 |
84 | return toActions({
85 | unscopeInit,
86 | unscopePassword,
87 | unscopeU2F,
88 | resetScopeState,
89 | unscopeResetTwoFactor
90 | });
91 | };
92 |
--------------------------------------------------------------------------------
/src/helpers/u2f.js:
--------------------------------------------------------------------------------
1 | import { sign, register } from 'u2f-api';
2 | import appProvider from 'frontend-commons/src/appProvider';
3 |
4 | export const ERROR_CODE = {
5 | SUCCESS: 0,
6 | OTHER_ERROR: 1,
7 | BAD_REQUEST: 2,
8 | CONFIGURATION_UNSUPPORTED: 3,
9 | DEVICE_INELIGIBLE: 4,
10 | TIMEOUT: 5
11 | };
12 |
13 | const ERROR_MAP = {
14 | [ERROR_CODE.SUCCESS]: () => {
15 | throw new Error('This is a success!');
16 | },
17 | [ERROR_CODE.OTHER_ERROR]: () => 'An error occurred',
18 | [ERROR_CODE.BAD_REQUEST]: () => 'An internal error occurred.',
19 | [ERROR_CODE.CONFIGURATION_UNSUPPORTED]: () => 'This security key is not supported.',
20 | [ERROR_CODE.DEVICE_INELIGIBLE]: (register) =>
21 | register ? 'This security key is already registered for your account!' : 'This security key is not recognized.',
22 | [ERROR_CODE.TIMEOUT]: () =>
23 | 'Looks like you are taking too long to respond, please try again with a bit of motivation.'
24 | };
25 |
26 | /**
27 | * Sends a SIGN request to the U2F device
28 | * @param {Object} U2FRequest - the U2FRequest
29 | * @param {Object[]} U2FRequest.RegisteredKeys - the registered keys
30 | * @param {String} U2FRequest.Challenge - the challenge
31 | * @return {Promise}
32 | */
33 | export async function signU2F({ RegisteredKeys: registeredKeys, Challenge: challenge }) {
34 | const { appId, timeout } = appProvider.getConfig('u2f');
35 |
36 | const signRequest = registeredKeys.map(({ Version: version, KeyHandle: keyHandle }) => ({
37 | version,
38 | keyHandle,
39 | appId,
40 | challenge
41 | }));
42 |
43 | return await sign(signRequest, timeout);
44 | }
45 |
46 | /**
47 | * Sends a REGISTER request to the U2F device.
48 | * @param {Object} U2FRequest - the U2FRequest
49 | * @param {Object[]} U2FRequest.RegisteredKeys - the registered keys
50 | * @param {String} U2FRequest.Challenge - the challenge
51 | * @param {String[]} U2FRequest.Versions - the different versions accepted by the server
52 | * @return {Promise}
53 | */
54 | export async function registerU2F({ RegisteredKeys: registeredKeys, Challenge: challenge, Versions: versions }) {
55 | const { appId, timeout } = appProvider.getConfig('u2f');
56 | const signRequest = registeredKeys.map(({ Version: version, KeyHandle: keyHandle }) => ({
57 | version,
58 | keyHandle
59 | }));
60 |
61 | const registerRequests = versions.map((version) => ({
62 | version,
63 | challenge,
64 | appId
65 | }));
66 |
67 | return await register(registerRequests, signRequest, timeout);
68 | }
69 |
70 | /**
71 | * Computes an error message from a U2F error code.
72 | * @param {number} errorCode - an error code.
73 | * @param register
74 | * @return {*}
75 | */
76 | export function getErrorMessage(errorCode, register = false) {
77 | if (ERROR_MAP[errorCode]) {
78 | return ERROR_MAP[errorCode](register);
79 | }
80 | throw new Error(`The error code "${errorCode}" does not exist`);
81 | }
82 |
--------------------------------------------------------------------------------
/src/tests/components/settings/security/TwoFactor/SetupTOTPModal/sharedSecret.test.js:
--------------------------------------------------------------------------------
1 | import render from 'preact-render-to-string';
2 | import { deep } from 'preact-render-spy';
3 |
4 | import { SharedSecret } from '../../../../../../components/settings/security/TwoFactor/SetupTOTPModal/sharedSecret';
5 |
6 | describe('SetupTOTP SharedSecret step', () => {
7 | test('regular display', () => {
8 | expect(
9 | render(
10 |
19 | )
20 | ).toMatchSnapshot();
21 |
22 | expect(render( )).toMatchSnapshot();
23 | });
24 |
25 | test('raw display', () => {
26 | const createSharedSecretAction = jest.fn();
27 |
28 | const tree = deep(
29 |
42 | );
43 | tree.find('a')
44 | .first()
45 | .simulate('click');
46 | expect(tree).toMatchSnapshot();
47 | expect(createSharedSecretAction).toHaveBeenCalledTimes(1);
48 |
49 | const groot = deep( );
50 | groot
51 | .find('a')
52 | .first()
53 | .simulate('click');
54 | expect(groot).toMatchSnapshot();
55 | expect(createSharedSecretAction).toHaveBeenCalledTimes(2);
56 | });
57 |
58 | test('submit and cancel', () => {
59 | const createSharedSecretAction = jest.fn();
60 |
61 | const onSubmit = jest.fn();
62 | const onCancel = jest.fn();
63 | const context = deep(
64 |
70 | );
71 |
72 | const event = { preventDefault: () => undefined };
73 |
74 | context.find('form').simulate('submit', event);
75 | expect(onSubmit).toHaveBeenCalled();
76 | expect(onCancel).not.toHaveBeenCalled();
77 |
78 | context.find('form').simulate('reset', event);
79 | expect(onSubmit).toHaveBeenCalledTimes(1); // the submit event
80 | expect(onCancel).toHaveBeenCalled(); // only once
81 |
82 | expect(createSharedSecretAction).toHaveBeenCalledTimes(1);
83 | });
84 | });
85 |
--------------------------------------------------------------------------------
/src/tests/testsHelpers/storeTools.js:
--------------------------------------------------------------------------------
1 | import render from 'preact-render-to-string';
2 | import { deep } from 'preact-render-spy';
3 |
4 | import store from '../../helpers/store';
5 | import { Provider } from 'unistore/full/preact';
6 | import defaultStore from '../../helpers/store';
7 |
8 | /**
9 | * Shallow-renders the component, embedded into a depth. Uses `preact-render-spy`.
10 | * @param {Component} component
11 | * @param {Object?} store - default to the exported value from the helpers/store
12 | * @param {int?} depth - the depth, initial value to 2, to bypass the provider
13 | * @return {RenderContext}
14 | * @function
15 | */
16 | export const shallowProvider = (component, store = defaultStore, depth = 2) => {
17 | return deep(
18 |
19 | {component}
20 | ,
21 | { depth }
22 | );
23 | };
24 |
25 | /**
26 | * Deep-renders of the component, embedded into a depth. Uses `preact-render-spy`
27 | * @param {Component} component
28 | * @param {Object?} store - default to the exported value from the helpers/store
29 | * @param {int?} depth - the depth, initial value to 2, to bypass the provider
30 | * @return {RenderContext}
31 | * @function
32 | */
33 | export const deepProvider = (component, store = defaultStore, depth = undefined) => {
34 | if (depth) {
35 | return deep(
36 |
37 | {component}
38 | ,
39 | { depth }
40 | );
41 | }
42 | return deep(
43 |
44 | {component}
45 |
46 | );
47 | };
48 |
49 | /**
50 | * renders the component embedded inside a Provider. Uses `preact-render-to-string`
51 | * @param {Component} component - the component to render
52 | * @param {Object?} store - the store, default to the exported value from the helpers/store
53 | * @return {*}
54 | * @function
55 | */
56 | export const renderProvided = (component, store = defaultStore) => {
57 | return render(
58 |
59 | {component}
60 |
61 | );
62 | };
63 |
64 | /**
65 | * Waits until the store is updated. The function will attached a listener (taken from the parameters) to each of the next update. Done is called once the last listener was invoked.
66 | * @param {Function} done - to be called once each listener have been consumed. It accepts one optional argument: the error.
67 | * @param {Function} action - the listener to be attached to the next update of the state.
68 | * @param {...Function} nextActions - a listener to be attached to the $n+1$ store update.
69 | * @function
70 | */
71 | export const waitForNewState = (done, action, ...nextActions) => {
72 | const next = nextActions.length ? () => waitForNewState(done, ...nextActions) : done;
73 |
74 | const subscriber = (state) => {
75 | try {
76 | action(state);
77 | store.unsubscribe(subscriber);
78 | next();
79 | } catch (e) {
80 | done(e);
81 | }
82 | };
83 | store.subscribe(subscriber);
84 | };
85 |
--------------------------------------------------------------------------------
/src/components/settings/security/TwoFactor/SaveRecoveryCodeModal/formTestCode.js:
--------------------------------------------------------------------------------
1 | import { h } from 'preact';
2 | import { connect } from 'unistore/full/preact';
3 |
4 | import settingsActions from '../../../../../actions/settings';
5 | import { Content as ModalContent, Footer as ModalFooter, Wrapper as ModalWrapper } from '../../../../ui/Modal';
6 |
7 | import styles from './index.css';
8 |
9 | const renderInfo = (result) => {
10 | if (typeof result === 'undefined') {
11 | // result is undefined until the input is lower than 6 characters.
12 | return Your recovery code will not be erased
;
13 | }
14 |
15 | return {result ? '✓ Test succeeded' : '⚠ Please test your recovery code to proceed'}
;
16 | };
17 |
18 | /**
19 | * Modal Form to test that a given code is one of the new recovery code.
20 | * @param {Object} props
21 | * @param {Function} props.onSubmit - triggers the next step.
22 | * @param {Function} props.onCancel - triggers the previous step.
23 | * @returns {ModalWrapper}
24 | */
25 | export const FormTestCode = ({
26 | onSubmit,
27 | onCancel,
28 | settings: {
29 | reset2FARecoveryCodes: { result, response = {} }
30 | },
31 | reset2FARecoveryCodesCheckNewCodeAction
32 | }) => {
33 | const model = { code: response.code };
34 |
35 | return (
36 | {
38 | e.preventDefault();
39 | onSubmit();
40 | }}
41 | onReset={(e) => {
42 | e.preventDefault();
43 | onCancel();
44 | }}
45 | >
46 |
47 |
48 | Test your recovery codes by entering one of your codes below. If you did not save your recovery
49 | codes, go back and save them.
50 |
51 |
52 |
Input your code
53 |
54 | {
56 | model.code = code;
57 | if (code.length === 8) {
58 | reset2FARecoveryCodesCheckNewCodeAction(model.code);
59 | }
60 | }}
61 | required
62 | value={model.code}
63 | type="code"
64 | id="verifyCode"
65 | placeholder="Code"
66 | disabled={!!result}
67 | autoFocus
68 | />
69 |
70 |
71 | {renderInfo(result)}
72 |
73 |
74 |
75 | Back
76 |
77 |
78 | Finish
79 |
80 |
81 |
82 | );
83 | };
84 |
85 | export default connect(
86 | 'settings',
87 | settingsActions
88 | )(FormTestCode);
89 |
--------------------------------------------------------------------------------
/src/components/settings/security/TwoFactor/SetupTOTPModal/confirmCode.js:
--------------------------------------------------------------------------------
1 | import { h, Component } from 'preact';
2 | import { connect } from 'unistore/full/preact';
3 |
4 | import settingsActions from '../../../../../actions/settings';
5 | import { Content as ModalContent, Footer as ModalFooter, Wrapper as ModalWrapper } from '../../../../ui/Modal';
6 |
7 | import styles from './index.css';
8 |
9 | export class ConfirmCode extends Component {
10 | constructor(props) {
11 | super(props);
12 | const {
13 | settings: { setupTOTP: { request: { TOTPCode } = {} } = {} }
14 | } = props;
15 | this.state = {
16 | code: TOTPCode
17 | };
18 | }
19 |
20 | componentWillReceiveProps(newProps) {
21 | const {
22 | settings: { setupTOTP: { status, error } = {} }
23 | } = newProps;
24 |
25 | if (status && status !== this.props.settings.setupTOTP.status) {
26 | if (status === 'success') {
27 | this.props.onSubmit();
28 | }
29 | if (status === 'failure') {
30 | this.props.onReset(error ? error.message || error : undefined);
31 | }
32 | this.setState({ loading: false });
33 | }
34 | }
35 |
36 | onSubmit(e) {
37 | e.preventDefault();
38 | this.setState({ loading: true });
39 | this.props.forbidClosure();
40 | this.props.enableTOTPAction(this.state.code);
41 | }
42 |
43 | render() {
44 | return (
45 | {
47 | this.onSubmit(e);
48 | }}
49 | onReset={(e) => {
50 | e.preventDefault();
51 | this.props.onCancel();
52 | }}
53 | >
54 |
55 | Test your new 2FA method:
56 |
57 |
Input your code
58 |
59 | {
61 | this.setState({ code });
62 | }}
63 | required
64 | value={this.state.code}
65 | disabled={this.state.loading}
66 | type="code"
67 | id="verifyCode"
68 | placeholder="Code"
69 | minLength="6"
70 | maxLength="6"
71 | autoFocus
72 | />
73 |
74 |
75 |
76 |
77 |
78 | Back
79 |
80 |
81 | Finish
82 |
83 |
84 |
85 | );
86 | }
87 | }
88 |
89 | export default connect(
90 | 'settings',
91 | settingsActions
92 | )(ConfirmCode);
93 |
--------------------------------------------------------------------------------
/src/tests/components/settings/security/TwoFactor/SaveRecoveryCodeModal/__snapshots__/formTestCode.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`test for SaveRecoveryCodeModal SaveCode step failure display 1`] = `
4 |
24 | `;
25 |
26 | exports[`test for SaveRecoveryCodeModal SaveCode step initial display 1`] = `
27 |
47 | `;
48 |
49 | exports[`test for SaveRecoveryCodeModal SaveCode step ongoing display 1`] = `
50 |
70 | `;
71 |
72 | exports[`test for SaveRecoveryCodeModal SaveCode step success display 1`] = `
73 |
93 | `;
94 |
--------------------------------------------------------------------------------
/src/components/settings/security/TwoFactor/SaveRecoveryCodeModal/presentation.js:
--------------------------------------------------------------------------------
1 | import { h, Component } from 'preact';
2 | import { connect } from 'unistore/full/preact';
3 |
4 | import settingsActions from '../../../../../actions/settings';
5 | import { Content as ModalContent, Footer as ModalFooter, Wrapper as ModalWrapper } from '../../../../ui/Modal';
6 | import TextButton from '../../../../ui/TextButton';
7 | import { CopyToClipboard } from 'react-copy-to-clipboard';
8 | import { downloadAsFile } from '../../../../../helpers/text';
9 |
10 | import styles from './index.css';
11 |
12 | export class Presentation extends Component {
13 | componentDidMount() {
14 | this.props.reset2FARecoveryCodesInitAction();
15 | }
16 |
17 | componentWillReceiveProps(newProps) {
18 | const {
19 | settings: {
20 | reset2FARecoveryCodes: { error }
21 | }
22 | } = newProps;
23 |
24 | if (this.props.settings.reset2FARecoveryCodes.error !== error && error) {
25 | this.props.onReset(error.message);
26 | }
27 | }
28 |
29 | /**
30 | * generates a TXT file, containing the codes, and download it on the browser.
31 | */
32 | downloadClicked() {
33 | const {
34 | settings: {
35 | reset2FARecoveryCodes: { request: { codes } = {} }
36 | }
37 | } = this.props;
38 |
39 | downloadAsFile('proton-recovery-codes.txt', codes);
40 | }
41 |
42 | renderCodes(codes = []) {
43 | if (!codes.length) {
44 | return Loading...
;
45 | }
46 | return [
47 |
48 | {codes.map((code) => (
49 |
50 | {code}
51 |
52 | ))}
53 | ,
54 |
55 | this.downloadClicked()}>DOWNLOAD CODES
56 |
57 | COPY CODES
58 |
59 |
60 | ];
61 | }
62 |
63 | /**
64 | * renders the content of the modal.
65 | * @returns {ModalContent}
66 | */
67 | renderContent() {
68 | const {
69 | settings: {
70 | reset2FARecoveryCodes: { request: { codes } = {} }
71 | }
72 | } = this.props;
73 |
74 | return (
75 |
76 |
77 | Please keep your recovery codes in a safe place. Otherwise, you can permanently lose access to your
78 | account if you loose your 2FA device
79 |
80 | Each recovery code can only be used once
81 | {this.renderCodes(codes)}
82 |
83 | );
84 | }
85 |
86 | render() {
87 | return (
88 | {
90 | e.preventDefault();
91 | this.props.onSubmit();
92 | }}
93 | onReset={(e) => {
94 | e.preventDefault();
95 | this.props.onCancel();
96 | }}
97 | >
98 | {this.renderContent()}
99 |
100 |
101 | Back
102 |
103 |
104 | Next
105 |
106 |
107 |
108 | );
109 | }
110 | }
111 |
112 | export default connect(
113 | 'settings',
114 | settingsActions
115 | )(Presentation);
116 |
--------------------------------------------------------------------------------
/src/tests/components/settings/security/TwoFactor/SaveRecoveryCodeModal/presentation.test.js:
--------------------------------------------------------------------------------
1 | import render from 'preact-render-to-string';
2 | import { deep } from 'preact-render-spy';
3 | import { saveAs } from 'file-saver';
4 |
5 | import { Presentation } from '../../../../../../components/settings/security/TwoFactor/SaveRecoveryCodeModal/presentation';
6 |
7 | jest.mock('file-saver');
8 |
9 | const renderPresentation = ({ codes, error } = {}, props = {}) => {
10 | return (
11 | null}
13 | {...props}
14 | settings={{
15 | reset2FARecoveryCodes: {
16 | error,
17 | request: {
18 | codes: codes || [
19 | '5bb72b16',
20 | '87739d2c',
21 | '6c11bf08',
22 | '37b9d9f5',
23 | '8561a630',
24 | '4684bfc5',
25 | '8a7a4335',
26 | '5c7235c5',
27 | '542a1827',
28 | '52d64018',
29 | '945dcb5d',
30 | '3bfe23c9'
31 | ]
32 | }
33 | }
34 | }}
35 | />
36 | );
37 | };
38 |
39 | describe('SaveRecoveryCodeModal presentation', () => {
40 | test('display', () => {
41 | expect(render(renderPresentation())).toMatchSnapshot();
42 | });
43 |
44 | test('loading display', () => {
45 | expect(
46 | render(
47 | null}
49 | settings={{
50 | reset2FARecoveryCodes: {}
51 | }}
52 | />
53 | )
54 | ).toMatchSnapshot();
55 | });
56 |
57 | test('download button', () => {
58 | const saveAsAction = jest.fn();
59 | saveAs.mockImplementation(saveAsAction);
60 |
61 | const context = deep(renderPresentation());
62 | context
63 | .find('a')
64 | .first()
65 | .simulate('click');
66 | expect(saveAsAction).toBeCalled();
67 | });
68 |
69 | test('submit and cancel', () => {
70 | const onSubmit = jest.fn();
71 | const onCancel = jest.fn();
72 | const context = deep(renderPresentation(undefined, { onSubmit, onCancel }));
73 |
74 | const event = { preventDefault: () => undefined };
75 |
76 | context.find('form').simulate('submit', event);
77 | expect(onSubmit).toHaveBeenCalled();
78 | expect(onCancel).not.toHaveBeenCalled();
79 |
80 | context.find('form').simulate('reset', event);
81 | expect(onSubmit).toHaveBeenCalledTimes(1); // the submit event
82 | expect(onCancel).toHaveBeenCalled(); // only once
83 | });
84 |
85 | test('reset when error received', () => {
86 | const onReset = jest.fn();
87 | const context = deep(renderPresentation(undefined, { onReset }));
88 | expect(onReset).toHaveBeenCalledTimes(0);
89 |
90 | let error = new Error('TestError');
91 | context.render(renderPresentation({ error }, { onReset }));
92 | expect(onReset).toHaveBeenCalledTimes(1);
93 | expect(onReset).toHaveBeenLastCalledWith(error.message);
94 |
95 | // calling with same props
96 | context.render(renderPresentation({ error }, { onReset }));
97 | expect(onReset).toHaveBeenCalledTimes(1); // not recalled
98 |
99 | error = new Error("It's not the intended behavior... ");
100 | context.render(renderPresentation({ error }, { onReset }));
101 | expect(onReset).toHaveBeenCalledTimes(2); // recalled
102 | expect(onReset).toHaveBeenLastCalledWith(error.message);
103 | });
104 | });
105 |
--------------------------------------------------------------------------------
/src/tests/components/auth/formSignU2F.test.js:
--------------------------------------------------------------------------------
1 | import { isSupported } from 'u2f-api';
2 |
3 | import ConnectedFormSignU2F, { FormSignU2F } from '../../../components/auth/formSignU2F';
4 | import { ERROR_CODE } from '../../../helpers/u2f';
5 | import store, { initialState } from '../../../helpers/store';
6 | import { shallow } from 'preact-render-spy';
7 | import { renderProvided, shallowProvider } from '../../testsHelpers/storeTools';
8 |
9 | jest.mock('../../../actions/authentication');
10 |
11 | describe('testing FormSignU2F component...', () => {
12 | afterEach(() => {
13 | store.setState(initialState, true);
14 | });
15 |
16 | test('error state', () => {
17 | const auth = {
18 | twoFactorResponse: {
19 | U2FResponse: {
20 | metaData: {
21 | code: 1
22 | }
23 | }
24 | }
25 | };
26 | store.setState({ auth });
27 | expect(renderProvided( , store)).toMatchSnapshot();
28 | });
29 |
30 | test('U2F not available on this browser', () => {
31 | isSupported.mockReturnValueOnce(false);
32 | const auth = {
33 | twoFactorResponse: {}
34 | };
35 | store.setState({ auth });
36 | expect(renderProvided( , store)).toMatchSnapshot();
37 | });
38 |
39 | test('correct state', () => {
40 | const auth = {
41 | twoFactorResponse: {}
42 | };
43 | store.setState({ auth });
44 | expect(renderProvided( , store)).toMatchSnapshot();
45 | });
46 |
47 | test('success state', () => {
48 | const auth = {
49 | twoFactorResponse: { success: true }
50 | };
51 | store.setState({ auth });
52 | expect(renderProvided( , store)).toMatchSnapshot();
53 | });
54 |
55 | test('retry button', () => {
56 | const auth = {
57 | twoFactorResponse: {
58 | U2FResponse: {
59 | metaData: {
60 | code: ERROR_CODE.OTHER_ERROR
61 | }
62 | }
63 | }
64 | };
65 |
66 | const loginU2FAction = jest.fn();
67 | const abortLoginAction = jest.fn();
68 |
69 | const component = shallow(
70 |
71 | );
72 |
73 | component.find('[onClick]').simulate('click');
74 | expect(loginU2FAction).toHaveBeenCalledTimes(2);
75 | expect(abortLoginAction).toHaveBeenCalledTimes(0);
76 | });
77 |
78 | test('retry button when timeout', () => {
79 | const auth = {
80 | twoFactorResponse: {
81 | U2FResponse: {
82 | metaData: {
83 | code: ERROR_CODE.TIMEOUT
84 | }
85 | }
86 | }
87 | };
88 |
89 | const loginU2FAction = jest.fn();
90 | const abortLoginAction = jest.fn();
91 |
92 | const component = shallow(
93 |
94 | );
95 |
96 | component.find('[onClick]').simulate('click');
97 | expect(loginU2FAction).toHaveBeenCalledTimes(0);
98 | expect(abortLoginAction).toHaveBeenCalledTimes(2);
99 | });
100 |
101 | test('mount with empty state', () => {
102 | const auth = {
103 | twoFactorResponse: {
104 | success: false
105 | }
106 | };
107 |
108 | const loginU2FAction = jest.fn();
109 | const abortLoginAction = jest.fn();
110 |
111 | const component = shallow(
112 |
113 | );
114 |
115 | expect(loginU2FAction).toHaveBeenCalledTimes(1);
116 | expect(abortLoginAction).toHaveBeenCalledTimes(0);
117 | });
118 | });
119 |
--------------------------------------------------------------------------------
/tasks/deploy.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const os = require('os');
4 | const Listr = require('listr');
5 | const execa = require('execa');
6 | const chalk = require('chalk');
7 | const del = require('del');
8 | const UpdaterRenderer = require('listr-update-renderer');
9 | const moment = require('moment');
10 |
11 | const { success, error, warn, json } = require('./helpers/log');
12 |
13 | const env = require('../env/config');
14 | const { CONFIG, branch } = env.getConfig('dist');
15 |
16 | const push = (branch) => {
17 | const commands = ['cd dist'];
18 | if (os.platform() === 'linux') {
19 | commands.push('git ls-files --deleted -z | xargs -r -0 git rm');
20 | } else {
21 | commands.push('(git ls-files --deleted -z || echo:) | xargs -0 git rm');
22 | }
23 | commands.push('git add --all');
24 | commands.push('git commit -m "New Release"');
25 | commands.push(`git push origin ${branch}`);
26 | commands.push('cd ..');
27 | commands.push(`git push origin ${branch}`);
28 | return execa.shell(commands.join(' && '), { shell: '/bin/bash' });
29 | };
30 |
31 | const pullDist = (branch) => {
32 | const commands = [
33 | `git fetch origin ${branch}:${branch}`,
34 | `git clone file://$PWD --depth 1 --single-branch --branch ${branch} dist`,
35 | 'cd dist',
36 | 'rm -rf *'
37 | ].join(' && ');
38 | return execa.shell(commands, { shell: '/bin/bash' });
39 | };
40 |
41 | const checkEnv = async () => {
42 | try {
43 | await execa.shell('[ -e ./env/env.json ]', { shell: '/bin/bash' });
44 | } catch (e) {
45 | throw new Error('You must have env.json to deploy. Cf the wiki');
46 | }
47 | };
48 |
49 | const getTasks = (branch, { isCI, flowType = 'single' }) => [
50 | {
51 | title: 'Clear previous dist',
52 | task: async () => {
53 | await del(['dist'], { dryRun: false });
54 | isCI && execa.shell('mkdir dist');
55 | }
56 | },
57 | {
58 | title: 'Setup config',
59 | enabled: () => !isCI,
60 | task() {
61 | return execa('tasks/setupConfig.js', process.argv.slice(2));
62 | }
63 | },
64 | {
65 | title: `Pull dist branch ${branch}`,
66 | enabled: () => !isCI,
67 | task: () => pullDist(branch)
68 | },
69 | {
70 | title: 'Build the application',
71 | task() {
72 | const args = process.argv.slice(2);
73 | return execa('npm', ['run', 'build', ...args, '--', '--no-clean', '--dest', 'dist']);
74 | }
75 | },
76 | {
77 | title: 'Move static files to root',
78 | task() {
79 | return execa.shell('cp src/app-id.json dist/app-id.json', { shell: '/bin/bash' });
80 | }
81 | },
82 | {
83 | title: `Push dist to ${branch}`,
84 | enabled: () => !isCI,
85 | task: () => push(branch)
86 | }
87 | ];
88 |
89 |
90 | // Custom local deploy for the CI
91 | const isCI = process.env.NODE_ENV_DIST === 'ci';
92 |
93 | if (!branch && !isCI) {
94 | throw new Error('You must define a branch name. --branch=XXX');
95 | }
96 |
97 | process.env.NODE_ENV_BRANCH = branch;
98 | process.env.NODE_ENV_API = CONFIG.apiUrl;
99 |
100 | !isCI && console.log(`➙ Branch: ${chalk.bgYellow(chalk.black(branch))}`);
101 | console.log(`➙ API: ${chalk.bgYellow(chalk.black(CONFIG.apiUrl))}`);
102 | console.log(`➙ SENTRY: ${chalk.bgYellow(chalk.black(process.env.NODE_ENV_SENTRY))}`);
103 | console.log('');
104 |
105 | env.argv.debug && json(CONFIG);
106 |
107 | const start = moment(Date.now());
108 | const tasks = new Listr(getTasks(branch, { isCI }), {
109 | renderer: UpdaterRenderer,
110 | collapse: false
111 | });
112 |
113 | tasks
114 | .run()
115 | .then(() => {
116 | const now = moment(Date.now());
117 | const total = now.diff(start, 'seconds');
118 | const time = total > 60 ? moment.utc(total * 1000).format('mm:ss') : `${total}s`;
119 |
120 | !isCI && success('App deployment done', { time });
121 | isCI && success(`Build CI app to the directory: ${chalk.bold('dist')}`, { time });
122 | })
123 | .catch(error);
124 |
--------------------------------------------------------------------------------
/src/components/settings/security/TwoFactor/AddU2FModal/formRegisterKey.js:
--------------------------------------------------------------------------------
1 | import { h, Component } from 'preact';
2 | import { connect } from 'unistore/full/preact';
3 |
4 | import settingsActions from '../../../../../actions/settings';
5 | import { Content as ModalContent, Footer as ModalFooter, Wrapper as ModalWrapper } from '../../../../ui/Modal';
6 | import TextButton from '../../../../ui/TextButton';
7 | import { getErrorMessage } from '../../../../../helpers/u2f';
8 |
9 | import styles from './index.css';
10 | import image from './sign-u2f.png';
11 |
12 | /**
13 | * Modal Form to register a new U2F Key.
14 | *
15 | * Fetches the challenge from the server, forward it to U2F API, and sends back the answer to the API.
16 | */
17 | export class FormRegisterKey extends Component {
18 | /**
19 | * when next button is pressed.
20 | * @param {Event} e
21 | */
22 | onSubmit(e) {
23 | e.preventDefault();
24 | this.props.onSubmit();
25 | }
26 |
27 | componentDidMount() {
28 | this.props.addU2FKeyRegisterAction();
29 | }
30 |
31 | componentWillReceiveProps(newProps) {
32 | const {
33 | settings: {
34 | addU2FKey: { status: newStatus }
35 | }
36 | } = newProps;
37 |
38 | const {
39 | settings: {
40 | addU2FKey: { status }
41 | }
42 | } = this.props;
43 |
44 | if (newStatus !== status && newStatus === 'finished') {
45 | this.props.forbidClosure();
46 | }
47 | }
48 |
49 | /**
50 | * Renders the status field.
51 | * @return {*}
52 | */
53 | renderStatus() {
54 | const {
55 | settings: {
56 | addU2FKey: { response: { name } = {}, status, error }
57 | }
58 | } = this.props;
59 |
60 | if (status !== 'failure') {
61 | return (
62 |
63 |
64 | Activate your key
65 |
66 | {status || 'fetching'}
67 | ...
68 |
69 |
70 |
71 |
72 | Name
73 | {name}
74 |
75 |
76 | );
77 | }
78 | const { metaData: { code } = {} } = error;
79 | if (code) {
80 | return (
81 |
82 | {getErrorMessage(code, true)}
83 | this.props.addU2FKeyRegisterAction()}>Retry
84 |
85 | );
86 | }
87 |
88 | this.props.onReset(error.message);
89 | }
90 |
91 | render() {
92 | const {
93 | settings: {
94 | addU2FKey: { status }
95 | }
96 | } = this.props;
97 |
98 | return (
99 | this.onSubmit(e)}
101 | onReset={(e) => {
102 | e.preventDefault();
103 | this.props.onCancel();
104 | }}
105 | >
106 |
107 |
108 |
109 | {this.renderStatus()}
110 |
111 |
112 |
113 | Back
114 |
115 |
116 | Next
117 |
118 |
119 |
120 | );
121 | }
122 | }
123 |
124 | export default connect(
125 | ['scope', 'settings'],
126 | settingsActions
127 | )(FormRegisterKey);
128 |
--------------------------------------------------------------------------------
/src/actions/settings/addU2FKey.js:
--------------------------------------------------------------------------------
1 | import { addU2FKey, getAddU2FChallenge } from 'frontend-commons/src/settings/security';
2 |
3 | import { toState, extended } from '../../helpers/stateFormatter';
4 | import { registerU2F } from '../../helpers/u2f';
5 |
6 | /**
7 | * @link { https://github.com/developit/unistore#usage }
8 | */
9 | export default (store) => {
10 | /**
11 | * update the `addU2FKey` state.
12 | * @param state
13 | * @param {Object} data the data to update.
14 | * @return {Object} new state
15 | * @private
16 | */
17 | function updateAddU2FKeyState(state, data) {
18 | store.setState(extended(state, 'settings.addU2FKey', data));
19 | return store.getState();
20 | }
21 |
22 | /**
23 | * Stores a name for a new U2F key. Erases any ongoing registration on the same browser.
24 | * @param state
25 | * @param {String} label
26 | */
27 | async function addU2FKeyLabel(state, label) {
28 | // erases any registering key. Not an issue, because the registration is supposed to be after the name setup.
29 | store.setState(extended(state, 'settings.addU2FKey', { response: { label } }));
30 | }
31 |
32 | /**
33 | * Fetches the challenge for the registration of a new key. If a response is already stored, using this one instead.
34 | * @return {Promise}
35 | * @private
36 | */
37 | async function fetchU2FRegisterChallenge() {
38 | const state = store.getState();
39 | const { errorCode, request } = state.settings.addU2FKey;
40 |
41 | if (!(errorCode && request && Object.keys(request).length)) {
42 | await updateAddU2FKeyState(state, { status: 'fetching' });
43 |
44 | await updateAddU2FKeyState(state, { request: await getAddU2FChallenge(true), status: 'pending' });
45 | } else {
46 | // if failure, no need to refetch the challenge
47 | await updateAddU2FKeyState(state, {
48 | status: 'pending'
49 | });
50 | }
51 | }
52 |
53 | /**
54 | * Sends the request to the U2F API.
55 | * @return {Promise}
56 | * @private
57 | */
58 | async function callU2FRegisterAPI() {
59 | const state = store.getState();
60 | const request = state.settings.addU2FKey.request;
61 | const u2fResponse = await registerU2F(request);
62 |
63 | await updateAddU2FKeyState(state, {
64 | u2fResponse,
65 | status: 'success'
66 | });
67 | return u2fResponse;
68 | }
69 |
70 | /**
71 | * post the U2F response to the API.
72 | * @return {Promise}
73 | * @private
74 | */
75 | async function postResponse() {
76 | const state = store.getState();
77 |
78 | const { response, u2fResponse } = state.settings.addU2FKey;
79 |
80 | const data = {
81 | ...response,
82 | ...u2fResponse
83 | };
84 |
85 | const {
86 | data: { TwoFactorRecoveryCodes, UserSettings }
87 | } = await addU2FKey(data, state.scope.creds, state.scope.response);
88 |
89 | const newState = {
90 | ...toState(state, 'settings', {
91 | addU2FKey: toState(state.settings, 'addU2FKey', { status: 'finished' }).addU2FKey,
92 | reset2FARecoveryCodes: toState(state.settings, 'reset2FARecoveryCodes', {
93 | request: { codes: TwoFactorRecoveryCodes }
94 | }).reset2FARecoveryCodes
95 | }),
96 | ...extended(state, 'config.settings.user', { ...UserSettings }),
97 | ...toState(state, 'scope', { used: true })
98 | };
99 |
100 | return store.setState(newState);
101 | }
102 |
103 | /**
104 | * Fetches and sends a U2F register request to the U2F API.
105 | * @param state
106 | * @returns {Promise}
107 | */
108 | async function addU2FKeyRegister(state) {
109 | await fetchU2FRegisterChallenge();
110 |
111 | try {
112 | await callU2FRegisterAPI();
113 | await postResponse();
114 | } catch (e) {
115 | return await updateAddU2FKeyState(store.getState(), { status: 'failure', error: e });
116 | }
117 | }
118 |
119 | return {
120 | addU2FKeyLabel,
121 | addU2FKeyRegister
122 | };
123 | };
124 |
--------------------------------------------------------------------------------
/src/components/settings/security/TwoFactor/SetupTOTPModal/sharedSecret.js:
--------------------------------------------------------------------------------
1 | import { h, Component } from 'preact';
2 | import { connect } from 'unistore/full/preact';
3 | import QRCode from 'qrcode.react';
4 |
5 | import { Content as ModalContent, Footer as ModalFooter, Wrapper as ModalWrapper } from '../../../../ui/Modal';
6 | import TextButton from '../../../../ui/TextButton';
7 | import settingsActions from '../../../../../actions/settings';
8 |
9 | import styles from './index.css';
10 |
11 | export class SharedSecret extends Component {
12 | state = { showingQRCode: true };
13 |
14 | componentDidMount() {
15 | this.props.createSharedSecretAction();
16 | }
17 |
18 | renderSwitchModeButton() {
19 | return (
20 |
21 | this.setState({ showingQRCode: !this.state.showingQRCode })}>
22 | {this.state.showingQRCode ? 'Enter key manually instead' : 'Scan QR code'}
23 |
24 |
25 | );
26 | }
27 |
28 | renderQRCode() {
29 | const { setupTOTP: { request: { qrURI } = {} } = {} } = this.props.settings;
30 |
31 | return (
32 |
33 |
34 | {this.props.message ||
35 | 'Scan this QR code with your two factor authentication device to set up your account. '}
36 |
37 | {this.renderSwitchModeButton()}
38 | {qrURI ? : Loading...
}
39 |
40 | );
41 | }
42 |
43 | renderRawInformation() {
44 | const { setupTOTP: { request: { interval, digits, secret } = {} } = {} } = this.props.settings;
45 |
46 | return (
47 |
48 |
49 | {this.props.message ||
50 | 'Manually enter this information into your two factor authentication device to set up your account. '}
51 |
52 | {this.renderSwitchModeButton()}
53 |
54 | {secret ? (
55 |
56 |
57 |
KEY
58 |
59 | {secret}
60 |
61 |
62 |
63 |
INTERVAL
64 |
65 | {interval} seconds
66 |
67 |
68 |
69 |
LENGTH
70 |
71 | {digits} digits
72 |
73 |
74 |
75 | ) : (
76 | Loading...
77 | )}
78 |
79 | );
80 | }
81 |
82 | render() {
83 | return (
84 | {
86 | e.preventDefault();
87 | this.props.onSubmit();
88 | }}
89 | onReset={(e) => {
90 | e.preventDefault();
91 | this.props.onCancel();
92 | }}
93 | >
94 | {this.state.showingQRCode ? this.renderQRCode() : this.renderRawInformation()}
95 |
96 |
97 | Back
98 |
99 |
100 | Next
101 |
102 |
103 |
104 | );
105 | }
106 | }
107 |
108 | export default connect(
109 | 'settings',
110 | settingsActions
111 | )(SharedSecret);
112 |
--------------------------------------------------------------------------------
/src/tests/actions/authentication.test.js:
--------------------------------------------------------------------------------
1 | import * as userConnector from 'frontend-commons/src/auth/userConnector';
2 |
3 | import store, { initialState } from '../../helpers/store';
4 | import { ERROR_CODE, signU2F } from '../../helpers/u2f';
5 | import authenticationActions from '../../actions/authentication';
6 | import { waitForNewState } from '../testsHelpers/storeTools';
7 |
8 | jest.mock('../../helpers/u2f');
9 | jest.mock('frontend-commons/src/auth/userConnector');
10 | jest.mock('frontend-commons/src/user/model');
11 | jest.mock('frontend-commons/src/appProvider');
12 |
13 | describe('authentication actions test', () => {
14 | const actions = authenticationActions(store);
15 |
16 | describe('loginU2F action', () => {
17 | afterAll(() => {
18 | signU2F.mockRestore();
19 | });
20 |
21 | beforeEach(() => {
22 | signU2F.mockClear();
23 | });
24 |
25 | test('success', async () => {
26 | const U2FResponse = {
27 | something: 'something'
28 | };
29 |
30 | signU2F.mockImplementation(() => U2FResponse);
31 | userConnector.login2FA.mockImplementation(() => ({
32 | config: {
33 | isUnlockable: true
34 | },
35 | user: { halli: 'hallo' }
36 | }));
37 |
38 | await actions.loginU2FAction({
39 | ...store.getState(),
40 | auth: {
41 | twoFactorData: {
42 | U2F: {
43 | something: 'somethat'
44 | }
45 | }
46 | }
47 | });
48 |
49 | expect(signU2F).toBeCalled();
50 | expect(signU2F).toHaveBeenCalledWith({
51 | something: 'somethat'
52 | });
53 |
54 | expect(userConnector.login2FA).toBeCalled();
55 | expect(userConnector.login2FA).toHaveBeenCalledWith({ U2FResponse });
56 |
57 | userConnector.login2FA.mockRestore();
58 | });
59 |
60 | test('failure U2F error', async (done) => {
61 | const error = {
62 | metaData: {
63 | code: ERROR_CODE.CONFIGURATION_UNSUPPORTED
64 | }
65 | };
66 | signU2F.mockImplementation(async () => {
67 | throw error;
68 | });
69 | waitForNewState(
70 | done,
71 | (state) => expect(state.auth.twoFactorResponse.U2FResponse).toEqual({}),
72 | (state) =>
73 | expect(state.auth.twoFactorResponse).toEqual({
74 | success: false,
75 | U2FResponse: error
76 | })
77 | );
78 | await actions.loginU2FAction({
79 | ...store.getState(),
80 | auth: {
81 | twoFactorData: {
82 | U2F: {
83 | something: 'somethat'
84 | }
85 | }
86 | }
87 | });
88 | });
89 | test('failure non-U2F error', async () => {
90 | const errorMessage = 'Some random error';
91 | signU2F.mockImplementation(async () => {
92 | throw new Error(errorMessage);
93 | });
94 |
95 | try {
96 | await actions.loginU2FAction({
97 | ...store.getState(),
98 | auth: {
99 | twoFactorData: {
100 | U2F: {
101 | something: 'somethat'
102 | }
103 | }
104 | }
105 | });
106 | } catch (e) {
107 | expect(e.message).toMatch(errorMessage);
108 | }
109 | });
110 | });
111 |
112 | test('abortLogin test', async () => {
113 | await actions.abortLoginAction({
114 | ...store.getState(),
115 | auth: {
116 | twoFactorData: {
117 | U2F: {
118 | something: 'somethat'
119 | }
120 | }
121 | }
122 | });
123 | expect(store.getState()).toEqual(initialState);
124 | });
125 | });
126 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "proton-account",
4 | "version": "0.0.0",
5 | "license": "MIT",
6 | "scripts": {
7 | "start": "if-env NODE_ENV=production && npm run -s serve || npm run -s dev",
8 | "build": "preact build --no-prerender",
9 | "config": "cross-env NODE_ENV=dev NODE_ENV_MODE=config tasks/setupConfig.js",
10 | "deploy": "cross-env NODE_ENV=dist tasks/deploy.js",
11 | "serve": "preact build --no-prerender && preact serve",
12 | "dev": "preact watch -p 3000 --no-prerender",
13 | "lint": "eslint src --quiet",
14 | "test": "cross-env NODE_ENV=test jest ./tests",
15 | "pretty": "prettier -c --write $(find src -type f -name '*.js')"
16 | },
17 | "lint-staged": {
18 | "*.js": [
19 | "prettier -c --write",
20 | "git add"
21 | ]
22 | },
23 | "eslintConfig": {
24 | "extends": [
25 | "eslint-config-synacor",
26 | "standard-preact"
27 | ],
28 | "rules": {
29 | "indent": [
30 | "error",
31 | 4
32 | ],
33 | "brace-style": [
34 | "error",
35 | "1tbs"
36 | ],
37 | "jsx-quotes": [
38 | "error",
39 | "prefer-double"
40 | ],
41 | "lines-around-comment": [
42 | 0
43 | ],
44 | "react/jsx-indent": [
45 | 2,
46 | 4
47 | ],
48 | "react/jsx-indent-props": [
49 | 2,
50 | 4
51 | ],
52 | "react/sort-comp": [
53 | 0
54 | ]
55 | }
56 | },
57 | "eslintIgnore": [
58 | "build/*"
59 | ],
60 | "devDependencies": {
61 | "babel-plugin-lodash": "^3.3.2",
62 | "babel-plugin-transform-class-properties": "^6.24.1",
63 | "babel-plugin-transform-react-jsx": "^6.24.1",
64 | "babel-plugin-transform-runtime": "^6.23.0",
65 | "chalk": "^2.4.1",
66 | "cross-env": "^5.2.0",
67 | "dedent": "^0.7.0",
68 | "del": "^3.0.0",
69 | "eslint": "^5.6.1",
70 | "eslint-config-standard-preact": "^1.1.6",
71 | "eslint-config-synacor": "^3.0.3",
72 | "execa": "^1.0.0",
73 | "husky": "^1.1.0",
74 | "identity-obj-proxy": "^3.0.0",
75 | "if-env": "^1.0.0",
76 | "jest": "^23.6.0",
77 | "jest-localstorage-mock": "^2.2.0",
78 | "jest-serializer-html-string": "^1.0.1",
79 | "lint-staged": "^7.3.0",
80 | "listr": "^0.14.1",
81 | "listr-update-renderer": "^0.4.0",
82 | "minimist": "^1.2.0",
83 | "preact-cli": "^2.1.0",
84 | "preact-render-spy": "^1.2.1",
85 | "preact-render-to-string": "^3.7.0",
86 | "prettier": "^1.14.3"
87 | },
88 | "dependencies": {
89 | "c-3po": "^0.8.1",
90 | "lodash": "^4.17.11",
91 | "file-saver": "^1.3.8",
92 | "moment": "^2.22.2",
93 | "hi-base32": "^0.5.0",
94 | "preact": "^8.2.6",
95 | "preact-cli-lodash": "^1.1.0",
96 | "preact-compat": "^3.17.0",
97 | "react-modal": "^3.6.1",
98 | "preact-router": "^2.5.7",
99 | "qrcode.react": "^0.8.0",
100 | "react-copy-to-clipboard": "^5.0.1",
101 | "u2f-api": "^1.0.6",
102 | "unistore": "^3.1.0"
103 | },
104 | "jest": {
105 | "snapshotSerializers": [
106 | "preact-render-spy/snapshot",
107 | "jest-serializer-html-string"
108 | ],
109 | "setupFiles": [
110 | "/src/tests/__mocks__",
111 | "jest-localstorage-mock"
112 | ],
113 | "testURL": "http://localhost:8080",
114 | "moduleFileExtensions": [
115 | "js",
116 | "jsx"
117 | ],
118 | "transform": {
119 | "^.+\\.jsx$": "babel-jest",
120 | "^.+\\.js$": "babel-jest"
121 | },
122 | "testPathIgnorePatterns": [],
123 | "transformIgnorePatterns": [
124 | "/node_modules/(?!frontend-commons).+\\.js$"
125 | ],
126 | "moduleNameMapper": {
127 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/src/tests/__mocks__/fileMocks.js",
128 | "\\.(css|less|scss)$": "identity-obj-proxy",
129 | "^./style$": "identity-obj-proxy",
130 | "^preact$": "/node_modules/preact/dist/preact.min.js",
131 | "^react$": "preact-compat",
132 | "^react-dom$": "preact-compat",
133 | "^create-react-class$": "preact-compat/lib/create-react-class",
134 | "^react-addons-css-transition-group$": "preact-css-transition-group"
135 | },
136 | "globals": {
137 | "window": {}
138 | }
139 | },
140 | "husky": {
141 | "hooks": {
142 | "pre-commit": "lint-staged"
143 | }
144 | }
145 | }
146 |
--------------------------------------------------------------------------------
/src/actions/settings/enableTOTP.js:
--------------------------------------------------------------------------------
1 | import base32 from 'hi-base32';
2 | import webcrypto from 'frontend-commons/src/crypto/webcrypto';
3 | import { enableTOTP as enableTOTPApi } from 'frontend-commons/src/settings/security';
4 |
5 | import { extended, toState } from '../../helpers/stateFormatter';
6 |
7 | export default (store) => {
8 | /**
9 | * Creates a shared secret for TOTP.
10 | * @param {Object} state
11 | * @return {Promise}
12 | */
13 | async function createSharedSecret(state) {
14 | if (state.settings.setupTOTP && state.settings.setupTOTP.request && state.settings.setupTOTP.request.qrURI) {
15 | return;
16 | }
17 | const randomBytes = webcrypto.getRandomValues(new Uint8Array(20));
18 | const sharedSecret = base32.encode(randomBytes);
19 |
20 | const primaryAddress = state.auth.user.Addresses && state.auth.user.Addresses.find(({ Keys }) => !!Keys);
21 | const identifier = primaryAddress ? primaryAddress.Email : state.auth.user.Name + '@protonmail';
22 |
23 | const interval = 30;
24 | const digits = 6;
25 | const qrURI = `otpauth://totp/${identifier}?secret=${sharedSecret}&issuer=ProtonMail&algorithm=SHA1&digits=${digits}&period=${interval}`;
26 |
27 | store.setState(
28 | extended(state, 'settings.setupTOTP', {
29 | request: {
30 | qrURI,
31 | interval,
32 | digits,
33 | secret: sharedSecret
34 | },
35 | status: 'init'
36 | })
37 | );
38 | }
39 |
40 | /**
41 | * verifies the state and the code.
42 | * @param state
43 | * @param code
44 | * @return {Promise} if the verification is valid
45 | */
46 | async function verifyParamsBeforeEnabling(state, code) {
47 | if (
48 | !state.settings.setupTOTP ||
49 | !state.settings.setupTOTP.request ||
50 | !state.settings.setupTOTP.request.secret
51 | ) {
52 | throw new Error('Please create a secret before enabling TOTP'); // this happens when enableTOTP is called before createSharedSecret
53 | }
54 | if (!code || code.length !== 6) {
55 | store.setState(
56 | extended(state, 'settings.setupTOTP', {
57 | status: 'failure',
58 | error: 'The code is not valid'
59 | })
60 | );
61 | return false;
62 | }
63 | return true;
64 | }
65 |
66 | /**
67 | * Enables TOTP on the backend side.
68 | * @param state
69 | * @param code
70 | * @return {Promise}
71 | */
72 | async function enableTOTP(state, code) {
73 | if (!(await verifyParamsBeforeEnabling(state, code))) {
74 | return;
75 | }
76 | const data = {
77 | TOTPConfirmation: code,
78 | TOTPSharedSecret: state.settings.setupTOTP.request.secret
79 | };
80 |
81 | store.setState(extended(state, 'settings.setupTOTP', { status: 'fetching' }));
82 | try {
83 | const {
84 | data: { TwoFactorRecoveryCodes, UserSettings }
85 | } = await enableTOTPApi(data, state.scope.creds, state.scope.response);
86 |
87 | const newState = {
88 | settings: {
89 | reset2FARecoveryCodes: {
90 | request: {
91 | codes: TwoFactorRecoveryCodes
92 | }
93 | },
94 | setupTOTP: {
95 | status: 'success',
96 | request: {
97 | ...state.settings.setupTOTP.request,
98 | TOTPConfirmation: code
99 | }
100 | }
101 | },
102 | ...extended(state, 'config.settings.user', { ...UserSettings }),
103 | ...toState(state, 'scope', { used: true })
104 | };
105 | store.setState(newState);
106 | } catch (e) {
107 | return store.setState(
108 | extended(state, 'settings.setupTOTP', {
109 | status: 'failure',
110 | error: e
111 | })
112 | );
113 | }
114 | }
115 |
116 | return {
117 | createSharedSecret,
118 | enableTOTP
119 | };
120 | };
121 |
--------------------------------------------------------------------------------
/env/config.js:
--------------------------------------------------------------------------------
1 | const extend = require('lodash/extend');
2 | const { execSync } = require('child_process');
3 | const argv = require('minimist')(process.argv.slice(2));
4 | const CONFIG_DEFAULT = require('./configDefault');
5 |
6 | const {
7 | STATS_CONFIG,
8 | STATS_ID,
9 | NO_STAT_MACHINE,
10 | API_TARGETS,
11 | AUTOPREFIXER_CONFIG,
12 | SENTRY_CONFIG,
13 | TOR_,
14 | U2F_CONFIG
15 | } = require('./config.constants');
16 |
17 | const hasEnv = () => Object.keys(SENTRY_CONFIG).length;
18 | const isProdBranch = (branch = process.env.NODE_ENV_BRANCH) => /-prod/.test(branch);
19 | const isTorBranch = (branch = process.env.NODE_ENV_BRANCH) => /-tor$/.test(branch);
20 | const typeofBranch = (branch = process.env.NODE_ENV_BRANCH) => {
21 | const [, type] = (branch || '').match(/deploy-(\w+)/) || [];
22 | if (/dev|beta|prod/.test(type)) {
23 | return type;
24 | }
25 |
26 | if (isTorBranch(branch)) {
27 | return 'prod';
28 | }
29 |
30 | if (type === 'alpha') {
31 | return 'red';
32 | }
33 |
34 | if (type) {
35 | return 'blue';
36 | }
37 | return 'dev';
38 | };
39 |
40 | const getStatsConfig = (deployBranch = '') => {
41 | const [, host = 'dev', subhost = 'a'] = deployBranch.split('-');
42 | return extend({}, STATS_CONFIG[host], STATS_ID[subhost]) || NO_STAT_MACHINE;
43 | };
44 |
45 | const getDefaultApiTarget = (defaultType = 'dev') => {
46 | if (!hasEnv()) {
47 | return 'prod';
48 | }
49 |
50 | if (process.env.NODE_ENV === 'dist') {
51 | const [, type] = (argv.branch || '').match(/\w+-(beta|prod)/) || [];
52 | if (type) {
53 | return type;
54 | }
55 |
56 | if (/red|alpha/.test(argv.branch || '')) {
57 | return 'dev';
58 | }
59 |
60 | return 'build';
61 | }
62 |
63 | return defaultType;
64 | };
65 |
66 | const isDistRelease = () => {
67 | return ['prod', 'beta'].includes(argv.api) || process.env.NODE_ENV === 'dist';
68 | };
69 |
70 | const getEnv = () => {
71 | if (isDistRelease()) {
72 | return argv.api || getDefaultApiTarget();
73 | }
74 | return argv.api || 'local';
75 | };
76 |
77 | const apiUrl = (type = getDefaultApiTarget(), branch = '') => {
78 | // Cannot override the branch when you deploy to live
79 | if (isProdBranch(branch) || isTorBranch(branch)) {
80 | return API_TARGETS.build;
81 | }
82 | return API_TARGETS[type] || API_TARGETS.dev;
83 | };
84 |
85 | const buildHost = () => {
86 | if (isTorBranch()) {
87 | return TOR_URL;
88 | }
89 | const host = isProdBranch() ? API_TARGETS.prod : process.env.NODE_ENV_API || apiUrl();
90 | return host.replace(/\api$/, '');
91 | };
92 |
93 | /**
94 | * Get correct sentry URL config for the current env
95 | * - on dev it's based on the API you specify
96 | * - on deploy it's based on the branch name
97 | * @return {String}
98 | */
99 | const sentryURL = () => {
100 | if (process.env.NODE_ENV === 'dist') {
101 | const env = typeofBranch(argv.branch);
102 | process.env.NODE_ENV_SENTRY = env;
103 | return SENTRY_CONFIG[env];
104 | }
105 | const env = getDefaultApiTarget(argv.api);
106 | process.env.NODE_ENV_SENTRY = env;
107 | return SENTRY_CONFIG[env];
108 | };
109 |
110 | const getHostURL = (encoded) => {
111 | const url = '/assets/host.png';
112 |
113 | if (encoded) {
114 | const encoder = (input) => `%${input.charCodeAt(0).toString(16)}`;
115 | return url
116 | .split('/')
117 | .map((chunk) => {
118 | if (chunk === '/') {
119 | return chunk;
120 | }
121 | return chunk
122 | .split('')
123 | .map(encoder)
124 | .join('');
125 | })
126 | .join('/');
127 | }
128 | return url;
129 | };
130 |
131 | const getConfig = (env = process.env.NODE_ENV) => {
132 | const CONFIG = extend({}, CONFIG_DEFAULT, {
133 | debug: env === 'dist' ? false : 'debug-app' in argv ? argv['debug-app'] : true,
134 | apiUrl: apiUrl(argv.api, argv.branch),
135 | sentryUrl: sentryURL(),
136 | app_version: argv['app-version'] || CONFIG_DEFAULT.app_version,
137 | api_version: `${argv['api-version'] || CONFIG_DEFAULT.api_version}`,
138 | articleLink: argv.article || CONFIG_DEFAULT.articleLink,
139 | statsConfig: getStatsConfig(argv.branch),
140 | u2f: U2F_CONFIG
141 | });
142 | return extend({ CONFIG }, { branch: argv.branch });
143 | };
144 |
145 | module.exports = {
146 | AUTOPREFIXER_CONFIG,
147 | getHostURL,
148 | getConfig,
149 | isDistRelease,
150 | getStatsConfig,
151 | argv,
152 | getEnv,
153 | hasEnv
154 | };
155 |
--------------------------------------------------------------------------------
/src/tests/components/settings/security/TwoFactor/AddU2FModal/__snapshots__/formRegisterKey.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`AddU2FModal step FormRegisterKey failure display with U2F error 1`] = `
4 |
17 | `;
18 |
19 | exports[`AddU2FModal step FormRegisterKey initial display 1`] = `
20 |
39 | `;
40 |
41 | exports[`AddU2FModal step FormRegisterKey initial display 2`] = `
42 |
61 | `;
62 |
63 | exports[`AddU2FModal step FormRegisterKey updating status 1`] = `
64 | preact-render-spy (1 nodes)
65 | -------
66 |
101 |
102 | `;
103 |
104 | exports[`AddU2FModal step FormRegisterKey updating status 2`] = `
105 | preact-render-spy (1 nodes)
106 | -------
107 |
142 |
143 | `;
144 |
145 | exports[`AddU2FModal step FormRegisterKey updating status 3`] = `
146 | preact-render-spy (1 nodes)
147 | -------
148 |
183 |
184 | `;
185 |
--------------------------------------------------------------------------------
/src/components/ui/SteppedModal/index.js:
--------------------------------------------------------------------------------
1 | import { h, Component } from 'preact';
2 |
3 | import Modal from '../Modal';
4 |
5 | /**
6 | * Modal in several steps.
7 | * @param props.isOpen - whether the step modal should be opened or not.
8 | * @param {Object[]} props.steps - the different steps to be proceeded.
9 | * @param {Component} props.steps[].components - the components of the current step.
10 | * @param {Function} props.onRequestClose - to be called when the callback is closed.
11 | * @param {?Function} props.onAfterOpen - to be called after the modal is opened.
12 | */
13 | export default class SteppedModal extends Component {
14 | state = {
15 | step: -1,
16 | previousAction: 'enter',
17 | mustSucceed: false
18 | };
19 |
20 | /**
21 | * called after the SteppedModal is opened.
22 | */
23 | onAfterOpen() {
24 | this.setState({ step: 0 });
25 |
26 | if (this.props.onAfterOpen) {
27 | this.props.onAfterOpen();
28 | }
29 | }
30 |
31 | /**
32 | * called after the SteppedModal is closed.
33 | * @param {Event} requestClosed - the event.
34 | * @param {Boolean} lastStepSuccess - whether the last step succeeded.
35 | */
36 | onRequestClose(requestClosed = null, lastStepSuccess = false) {
37 | if (!(this.state.mustSucceed || this.props.steps[this.state.step].mustSucceed) || lastStepSuccess) {
38 | this.setState({ step: -1 });
39 |
40 | if (this.props.beforeDismiss) {
41 | this.props.beforeDismiss(lastStepSuccess);
42 | }
43 | this.props.onRequestClose();
44 | }
45 | }
46 |
47 | onSkipStep() {
48 | if (this.state.previousAction === 'next' || this.state.previousAction === 'enter') {
49 | return this.onNextStep();
50 | }
51 | return this.onPreviousStep();
52 | }
53 |
54 | /**
55 | * Triggers the next step. If the last step is reached, closes the modal.
56 | */
57 | onNextStep() {
58 | const state = this.state;
59 |
60 | if (this.state.step + 1 >= this.props.steps.length) {
61 | // if last step
62 | this.onRequestClose(null, true);
63 | } else {
64 | this.setState({
65 | step: state.step + 1,
66 | previousAction: 'next',
67 | mustSucceed: false,
68 | message: null
69 | });
70 | }
71 | }
72 |
73 | /**
74 | * Triggers the previous step. If the current step is the first step, the modal is closed.
75 | */
76 | onPreviousStep() {
77 | const state = this.state;
78 | if (this.state.step <= 0) {
79 | this.onRequestClose();
80 | } else {
81 | this.setState({
82 | step: state.step - 1,
83 | previousAction: 'previous',
84 | mustSucceed: false,
85 | message: null
86 | });
87 | }
88 | }
89 |
90 | onReset(message = undefined) {
91 | if (this.props.beforeDismiss) {
92 | this.props.beforeDismiss(false, true);
93 | }
94 | this.setState({
95 | message,
96 | step: 0,
97 | previousAction: 'enter',
98 | mustSucceed: false
99 | });
100 | }
101 |
102 | /**
103 | * if triggered, it will no longer be possible to close the modal during the current step.
104 | */
105 | forbidClosure() {
106 | this.setState({ mustSucceed: true });
107 | }
108 |
109 | /**
110 | * Computes the title of the current step.
111 | * @return {String} the current step.
112 | */
113 | computeCurrentTitle() {
114 | if (this.state.step >= this.props.steps.length || this.state.step === -1) {
115 | return 'Loading';
116 | }
117 | return this.props.steps[this.state.step].title;
118 | }
119 |
120 | /**
121 | * renders the current step.
122 | * @return {Component}
123 | */
124 | renderCurrentStep() {
125 | if (this.state.step >= this.props.steps.length || this.state.step === -1) {
126 | return null;
127 | }
128 | return this.props.steps[this.state.step].component({
129 | onNextStep: () => this.onNextStep(),
130 | onPreviousStep: () => this.onPreviousStep(),
131 | onSkipStep: () => this.onSkipStep(),
132 | onReset: (message) => this.onReset(message),
133 | forbidClosure: () => this.forbidClosure(),
134 | message: this.state.message
135 | });
136 | }
137 |
138 | render() {
139 | if (!this.props.steps || !this.props.steps.length) return null;
140 | return (
141 | this.onAfterOpen()}
144 | onRequestClose={(requestClosed = null, lastStepSuccess = false) =>
145 | this.onRequestClose(requestClosed, lastStepSuccess)
146 | }
147 | title={this.computeCurrentTitle()}
148 | >
149 | {this.renderCurrentStep()}
150 |
151 | );
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/src/tests/components/settings/security/TwoFactor/AddU2FModal/formRegisterKey.test.js:
--------------------------------------------------------------------------------
1 | import render from 'preact-render-to-string';
2 | import { deep } from 'preact-render-spy';
3 |
4 | import { FormRegisterKey } from '../../../../../../components/settings/security/TwoFactor/AddU2FModal/formRegisterKey';
5 | import { ERROR_CODE } from '../../../../../../helpers/u2f';
6 |
7 | describe('AddU2FModal step FormRegisterKey', () => {
8 | test('initial display', () => {
9 | expect(render( )).toMatchSnapshot();
10 |
11 | const settings = { addU2FKey: { response: { name: 'Test name' }, status: undefined, error: undefined } };
12 | expect(render( )).toMatchSnapshot();
13 | });
14 |
15 | test('failure display with U2F error', () => {
16 | const settings = {
17 | addU2FKey: {
18 | response: { name: 'Test name' },
19 | status: 'failure',
20 | error: { metaData: { code: ERROR_CODE.OTHER_ERROR } }
21 | }
22 | };
23 | expect(render( )).toMatchSnapshot();
24 | });
25 |
26 | test('failure display with non-U2F error', () => {
27 | const onReset = jest.fn();
28 | const settings = {
29 | addU2FKey: {
30 | response: { name: 'Test name' },
31 | status: 'failure',
32 | error: new Error('Random error?./')
33 | }
34 | };
35 | render( );
36 | expect(onReset).toBeCalledWith(settings.addU2FKey.error.message);
37 | });
38 |
39 | test('addU2FAction is correctly called', () => {
40 | const addU2FKeyRegisterAction = jest.fn();
41 | const settings = {
42 | addU2FKey: {
43 | response: { name: 'Test name' },
44 | status: 'failure',
45 | error: { metaData: { code: ERROR_CODE.OTHER_ERROR } }
46 | }
47 | };
48 | const context = deep( );
49 | expect(addU2FKeyRegisterAction).toHaveBeenCalledTimes(1);
50 |
51 | context
52 | .find('a')
53 | .first()
54 | .simulate('click');
55 | expect(addU2FKeyRegisterAction).toHaveBeenCalledTimes(2);
56 | });
57 |
58 | test('submit and cancel', () => {
59 | const addU2FKeyRegisterAction = jest.fn();
60 |
61 | const onSubmit = jest.fn();
62 | const onCancel = jest.fn();
63 |
64 | const event = { preventDefault: () => undefined };
65 |
66 | const context = deep(
67 |
73 | );
74 |
75 | context.find('form').simulate('submit', event);
76 | expect(onSubmit).toHaveBeenCalled();
77 | expect(onCancel).not.toHaveBeenCalled();
78 |
79 | context.find('form').simulate('reset', event);
80 | expect(onSubmit).toHaveBeenCalledTimes(1); // the submit event
81 | expect(onCancel).toHaveBeenCalled(); // only once
82 |
83 | expect(addU2FKeyRegisterAction).toHaveBeenCalledTimes(1);
84 | });
85 |
86 | test('updating status', () => {
87 | const addU2FKeyRegisterAction = jest.fn();
88 | const forbidClosure = jest.fn();
89 |
90 | const context = deep(
91 |
96 | );
97 |
98 | expect(context).toMatchSnapshot();
99 | expect(forbidClosure).toHaveBeenCalledTimes(0);
100 |
101 | context.render(
102 |
107 | );
108 | expect(context).toMatchSnapshot();
109 | expect(forbidClosure).toHaveBeenCalledTimes(0);
110 |
111 | context.render(
112 |
117 | );
118 | expect(context).toMatchSnapshot();
119 | expect(forbidClosure).toHaveBeenCalledTimes(1);
120 |
121 | context.render(
122 |
127 | );
128 | // second time with the same status, we don't expect forbidClosure is recalled
129 | expect(forbidClosure).toHaveBeenCalledTimes(1);
130 | });
131 | });
132 |
--------------------------------------------------------------------------------
/src/actions/authentication.js:
--------------------------------------------------------------------------------
1 | import { route } from 'preact-router';
2 | import { addLocale, useLocale } from 'c-3po';
3 | import { loadExtendedConfig } from 'frontend-commons/src/user/model';
4 | import * as userConnector from 'frontend-commons/src/auth/userConnector';
5 | import appProvider from 'frontend-commons/src/appProvider';
6 |
7 | import { signU2F } from '../helpers/u2f';
8 | import toActions from '../helpers/toActions';
9 | import { extended, toState } from '../helpers/stateFormatter';
10 | import { initialState } from '../helpers/store';
11 |
12 | /**
13 | * @link { https://github.com/developit/unistore#usage }
14 | */
15 | const actions = (store) => {
16 | async function loadUserConfig(state, user) {
17 | const { organization, payment, settings } = await loadExtendedConfig(user);
18 | appProvider.loadI18n(settings.user.Locale);
19 | store.setState({
20 | config: { settings, payment, organization }
21 | });
22 | }
23 |
24 | async function logout(state) {
25 | await userConnector.logout();
26 | const data = toState(state, 'auth', {
27 | user: {},
28 | isLoggedIn: false,
29 | step: 'login'
30 | });
31 | store.setState({ config: null });
32 | store.setState(data);
33 | route('/', data);
34 | }
35 |
36 | async function unlock(state, opt) {
37 | const { user } = await userConnector.unlock(opt);
38 | await loadUserConfig(state, user);
39 | toState(state, 'auth', {
40 | user,
41 | isLoggedIn: true,
42 | step: ''
43 | });
44 | store.setState(
45 | toState(state, 'auth', {
46 | user,
47 | isLoggedIn: true,
48 | step: ''
49 | })
50 | );
51 |
52 | route('/dashboard', store.getState());
53 | }
54 |
55 | /**
56 | * Login using a U2F key.
57 | * @param state
58 | * @returns {Promise<*>} calls @link{login2FA}
59 | */
60 | async function loginU2F(state) {
61 | try {
62 | store.setState(extended(state, 'auth.twoFactorResponse', { U2FResponse: {} }));
63 | const result = await signU2F(state.auth.twoFactorData.U2F);
64 | return login2FA(state, { U2FResponse: result });
65 | } catch (e) {
66 | const { metaData: { code } = {} } = e;
67 | if (!code) {
68 | throw e;
69 | }
70 | return store.setState(
71 | toState(state, 'auth', {
72 | twoFactorResponse: {
73 | success: false,
74 | U2FResponse: e
75 | }
76 | })
77 | );
78 | }
79 | }
80 |
81 | async function login2FA(state, opt) {
82 | const { config, user } = await userConnector.login2FA(opt);
83 |
84 | if (config) {
85 | if (config.isUnlockable) {
86 | return store.setState(
87 | toState(state, 'auth', {
88 | user,
89 | isLoggedIn: false,
90 | step: 'unlock'
91 | })
92 | );
93 | }
94 | }
95 |
96 | const data = toState(state, 'auth', {
97 | user,
98 | isLoggedIn: true,
99 | step: ''
100 | });
101 | store.setState(data);
102 | await loadUserConfig(state, user);
103 | route('dashboard', data);
104 | }
105 |
106 | async function login(state, opt) {
107 | console.log('login.load', opt);
108 | const { config, user } = await userConnector.login(opt);
109 | console.log('login.success', { config, user, opt });
110 |
111 | if (config) {
112 | const data = { user, isLoggedIn: false };
113 |
114 | if (config.is2FA) {
115 | data.step = '2fa';
116 | data.twoFactorData = { ...config.twoFactorData };
117 | }
118 |
119 | if (config.isUnlockable) {
120 | data.step = 'unlock';
121 | }
122 | return store.setState(toState(state, 'auth', data));
123 | }
124 |
125 | store.setState(
126 | toState(state, 'auth', {
127 | user,
128 | isLoggedIn: true,
129 | step: ''
130 | })
131 | );
132 |
133 | !opt.raw && (await loadUserConfig(state, user));
134 | route('/dashboard', store.getState());
135 | }
136 |
137 | async function abortLogin(state) {
138 | store.setState(initialState, true);
139 | route('/', initialState);
140 | }
141 |
142 | async function loadAuthUser(state) {
143 | appProvider.setAppI18n(addLocale, useLocale);
144 | const data = await userConnector.reloadAuth();
145 | const isLoggedIn = !!Object.keys(data.user).length;
146 | store.setState(
147 | toState(state, 'auth', {
148 | ...data,
149 | isLoggedIn
150 | })
151 | );
152 |
153 | isLoggedIn && (await loadUserConfig(state, data.user));
154 | }
155 |
156 | return toActions({
157 | loadUserConfig,
158 | login,
159 | logout,
160 | unlock,
161 | login2FA,
162 | abortLogin,
163 | loadAuthUser,
164 | loginU2F
165 | });
166 | };
167 |
168 | export default actions;
169 |
--------------------------------------------------------------------------------
/src/tests/components/settings/security/TwoFactor/SetupTOTPModal/__snapshots__/sharedSecret.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`SetupTOTP SharedSecret step raw display 1`] = `
4 | preact-render-spy (1 nodes)
5 | -------
6 |
60 |
61 | `;
62 |
63 | exports[`SetupTOTP SharedSecret step raw display 2`] = `
64 | preact-render-spy (1 nodes)
65 | -------
66 |
101 |
102 | `;
103 |
104 | exports[`SetupTOTP SharedSecret step regular display 1`] = `
105 |
122 | `;
123 |
124 | exports[`SetupTOTP SharedSecret step regular display 2`] = `
125 |
139 | `;
140 |
--------------------------------------------------------------------------------
/src/actions/settings/index.js:
--------------------------------------------------------------------------------
1 | import {
2 | disableTOTP as disableTOTPApi,
3 | disableTwoFactor as disableTwoFactorApi,
4 | removeU2FKey,
5 | resetRecoveryCodes
6 | } from 'frontend-commons/src/settings/security';
7 |
8 | import addU2FKeyActions from './addU2FKey';
9 | import enableTOTPActions from './enableTOTP';
10 | import toActions from '../../helpers/toActions';
11 | import { error, success } from '../../helpers/notification';
12 | import { toState, extended } from '../../helpers/stateFormatter';
13 |
14 | /**
15 | * @link { https://github.com/developit/unistore#usage }
16 | */
17 | const actions = (store) => {
18 | /**
19 | * Resets the state for the actions
20 | * @param {Object} state
21 | * @param {string[]} actions - the actions to reset (for the settings store only)
22 | * @returns {Promise}
23 | */
24 | async function resetStore(state, actions) {
25 | const newState = actions.reduce((acc, action) => {
26 | if (state.settings[action]) {
27 | return {
28 | settings: {
29 | ...state.settings,
30 | ...acc.settings,
31 | [action]: {}
32 | }
33 | };
34 | }
35 | return acc;
36 | }, {});
37 |
38 | if (Object.keys(newState).length) {
39 | return store.setState(newState);
40 | }
41 | }
42 |
43 | /**
44 | * Initializes the reset2FARecoveryCodes procedure.
45 | *
46 | * Fetches new codes if none are already in the state.
47 | * @param {Object} state
48 | * @returns {Promise}
49 | */
50 | async function reset2FARecoveryCodesInit(state) {
51 | const {
52 | settings: {
53 | reset2FARecoveryCodes: { request: { codes } = {} }
54 | }
55 | } = state;
56 |
57 | try {
58 | if (!codes || !codes.length) {
59 | const response = await resetRecoveryCodes(state.scope.creds, state.scope.response);
60 | store.setState(
61 | toState(
62 | state,
63 | 'settings',
64 | toState(state.settings, 'reset2FARecoveryCodes', {
65 | request: { codes: response.data.TwoFactorRecoveryCodes }
66 | })
67 | )
68 | );
69 | }
70 | } catch (e) {
71 | store.setState(extended(state, 'settings.reset2FARecoveryCodes', { error: e }));
72 | }
73 | }
74 |
75 | /**
76 | * Verifies a given code is in the new codes.
77 | * @param {Object} state
78 | * @param {string} code - the code to verify.
79 | * @returns {Promise}
80 | */
81 | async function reset2FARecoveryCodesCheckNewCode(state, code) {
82 | const {
83 | settings: {
84 | reset2FARecoveryCodes: { request: { codes = [] } = {} }
85 | }
86 | } = state;
87 |
88 | return store.setState(
89 | extended(state, 'settings.reset2FARecoveryCodes', {
90 | result: codes.indexOf(code) >= 0,
91 | response: { code }
92 | })
93 | );
94 | }
95 |
96 | /**
97 | * Updates the settings from the store.
98 | * @param {Object} state
99 | * @param {Object} result
100 | * @param {Object} result.data
101 | * @param {Object} result.data.UserSettings - the new UserSettings
102 | * @private
103 | */
104 | function updateUserSettingsFromResponse(state, result) {
105 | store.setState(extended(state, 'config.settings.user', { ...result.data.UserSettings }));
106 | }
107 |
108 | /**
109 | * Sends a delete request to the API.
110 | * @param {Object} state
111 | * @param {Object} u2fKey - the U2F Key to delete.
112 | * @param {String} u2fKey.KeyHandle - the key handle of the U2F Key.
113 | * @returns {Promise}
114 | */
115 | async function deleteU2FKey(state, u2fKey) {
116 | try {
117 | updateUserSettingsFromResponse(
118 | state,
119 | await removeU2FKey(u2fKey.KeyHandle, state.scope.creds, state.scope.response)
120 | );
121 | success('Your key was successfully deleted');
122 | } catch (e) {
123 | error(e);
124 | }
125 | }
126 |
127 | /**
128 | * Sends a delete request for TOTP to the API
129 | * @param {Object} state
130 | * @return {Promise}
131 | */
132 | async function disableTOTP(state) {
133 | try {
134 | updateUserSettingsFromResponse(state, await disableTOTPApi(state.scope.creds, state.scope.response));
135 | success('2FA via application was successfully disabled');
136 | } catch (e) {
137 | error(e);
138 | }
139 | }
140 |
141 | /**
142 | * Disables Two Factor
143 | * @param {Object} state
144 | * @returns {Promise}
145 | */
146 | async function disableTwoFactor(state) {
147 | try {
148 | updateUserSettingsFromResponse(state, await disableTwoFactorApi(state.scope.creds, state.scope.response));
149 | success('Two Factor authentication was successfully disabled');
150 | } catch (e) {
151 | error(e);
152 | }
153 | }
154 |
155 | return toActions({
156 | reset2FARecoveryCodesCheckNewCode,
157 | reset2FARecoveryCodesInit,
158 | resetStore,
159 | deleteU2FKey,
160 | disableTOTP,
161 | disableTwoFactor,
162 | ...enableTOTPActions(store),
163 | ...addU2FKeyActions(store)
164 | });
165 | };
166 |
167 | export default actions;
168 |
--------------------------------------------------------------------------------
/src/tests/components/settings/security/TwoFactor/__snapshots__/index.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`TwoFactorSettings 2FA disabled 1`] = `
4 |
5 |
Two-Factor Authentication
6 |
Two-factor authentication is currently off.
7 |
8 |
2FA via Application
9 |
Enable
10 |
11 |
12 |
2FA via Security Key
13 |
Enable
14 |
15 |
16 |
17 | `;
18 |
19 | exports[`TwoFactorSettings Everything enable 1`] = `
20 |
21 |
Two-Factor Authentication
22 |
Two-factor authentication is currently on.
23 |
Turn off
24 |
25 |
26 |
2FA via Application
27 |
Disable
28 |
33 |
34 |
35 |
2FA via Security Key
36 |
Add another key
37 |
38 |
39 |
40 |
41 | Delete
42 |
43 |
44 |
45 | Delete
46 |
47 |
48 |
49 | Compromised
50 | Delete
51 |
52 |
53 |
54 | Delete
55 |
56 |
57 |
58 | `;
59 |
60 | exports[`TwoFactorSettings TOTP only enable 1`] = `
61 |
62 |
Two-Factor Authentication
63 |
Two-factor authentication is currently on.
64 |
Turn off
65 |
66 |
67 |
2FA via Application
68 |
Disable
69 |
74 |
75 |
76 |
2FA via Security Key
77 |
Enable
78 |
79 |
80 |
81 | `;
82 |
83 | exports[`TwoFactorSettings U2F not supported 1`] = `
84 |
85 |
Two-Factor Authentication
86 |
Two-factor authentication is currently on.
87 |
Turn off
88 |
89 |
90 |
2FA via Application
91 |
Disable
92 |
97 |
98 |
99 |
2FA via Security Key
100 |
Add another key
101 |
102 |
103 |
104 |
105 | Delete
106 |
107 |
108 |
109 | Delete
110 |
111 |
112 |
113 | `;
114 |
115 | exports[`TwoFactorSettings U2F only enable 1`] = `
116 |
117 |
Two-Factor Authentication
118 |
Two-factor authentication is currently on.
119 |
Turn off
120 |
121 |
122 |
2FA via Application
123 |
Enable
124 |
129 |
130 |
131 |
2FA via Security Key
132 |
Add another key
133 |
134 |
135 |
136 |
137 | Delete
138 |
139 |
140 |
141 | Delete
142 |
143 |
144 |
145 | Compromised
146 | Delete
147 |
148 |
149 |
150 | Delete
151 |
152 |
153 |
154 | `;
155 |
--------------------------------------------------------------------------------
/src/components/settings/security/TwoFactor/index.js:
--------------------------------------------------------------------------------
1 | import { h, Component } from 'preact';
2 | import { connect } from 'unistore/full/preact';
3 | import { isSupported } from 'u2f-api';
4 |
5 | import U2FKeyList from './U2FKeyList/U2FKeyList';
6 | import SteppedModal from '../../../ui/SteppedModal';
7 | import ConfirmModal from '../../../ui/ConfirmModal';
8 | import TextButton from '../../../ui/TextButton';
9 | import settingsAction from '../../../../actions/settings';
10 | import {
11 | steps as SaveRecoveryCodesSteps,
12 | beforeDismiss as SaveRecoveryCodesBeforeDismiss
13 | } from './SaveRecoveryCodeModal';
14 | import { beforeDismiss as AddU2FModalBeforeDismiss, steps as AddU2FModalSteps } from './AddU2FModal';
15 | import { beforeDismiss as SetupTOTPModalBeforeDismiss, steps as SetupTOTPModalSteps } from './SetupTOTPModal';
16 |
17 | import style from './style.css';
18 |
19 | /**
20 | * @param {Object} props
21 | * @param {Object} props.user
22 | * @param {Int} props.user.TwoFactor - whether 2FA is active or not.
23 | * @param {Int} props.user.TOTP - whether TOTP is active or not.
24 | * @param {Object[]} props.user.U2FKeys - the list of U2FKeys.
25 | * @param {Int} props.user.U2FKeys[].Compromised - whether the key is Compromised or not.
26 | * @param {String} props.user.U2FKeys[].KeyHandle - the KeyHandle of the U2FKey.
27 | * @param {String} props.user.U2FKeys[].Label - the Label of the Key.
28 | */
29 | class TwoFactorSettings extends Component {
30 | state = {
31 | modal: '',
32 | SaveRecoveryCodesModalOpen: false,
33 | U2FModalOpen: false,
34 | DisableU2FModalOpen: false
35 | };
36 |
37 | openModal(modalName) {
38 | this.setState({ modal: modalName });
39 | }
40 |
41 | closeModal() {
42 | this.setState({ modal: '' });
43 | }
44 |
45 | renderDisableTwoFactorModal() {
46 | return (
47 | this.closeModal()}
52 | onConfirm={() => {
53 | this.props.disableTwoFactorAction();
54 | }}
55 | onCancel={() => {}}
56 | >
57 | Are you sure you want to disable totally Two Factor Authentication?
58 |
59 | );
60 | }
61 |
62 | renderDisableTOTPModal() {
63 | return (
64 | this.closeModal()}
69 | onConfirm={() => {
70 | this.props.disableTOTPAction();
71 | }}
72 | onCancel={() => {}}
73 | >
74 | Are you sure you want to disable 2FA via application?
75 |
76 | );
77 | }
78 |
79 | renderAddU2FModal() {
80 | return (
81 | this.closeModal()}
84 | steps={AddU2FModalSteps}
85 | beforeDismiss={AddU2FModalBeforeDismiss}
86 | />
87 | );
88 | }
89 |
90 | renderSetupTOTPModal() {
91 | return (
92 | this.closeModal()}
95 | steps={SetupTOTPModalSteps}
96 | beforeDismiss={SetupTOTPModalBeforeDismiss}
97 | />
98 | );
99 | }
100 |
101 | renderSaveRecoveryCodesModal() {
102 | return (
103 | this.closeModal()}
106 | beforeDismiss={SaveRecoveryCodesBeforeDismiss}
107 | steps={SaveRecoveryCodesSteps}
108 | />
109 | );
110 | }
111 |
112 | render() {
113 | const { TwoFactor, TOTP, U2FKeys = [] } = this.props;
114 |
115 | const u2fClasses = [style.item];
116 | if (TwoFactor) {
117 | u2fClasses.push(style.lastItem);
118 | }
119 |
120 | return (
121 |
122 | {this.renderDisableTwoFactorModal()}
123 | {this.renderSetupTOTPModal()}
124 | {this.renderDisableTOTPModal()}
125 | {this.renderAddU2FModal()}
126 | {this.renderSaveRecoveryCodesModal()}
127 |
Two-Factor Authentication
128 |
129 | Two-factor authentication is currently {TwoFactor ? 'on' : 'off'}.{' '}
130 | {!!TwoFactor && this.openModal('Disable2FA')}>Turn off }
131 |
132 |
133 |
2FA via Application
134 |
this.openModal(TOTP ? 'DisableTOTP' : 'SetupTOTP')}>
135 | {TOTP ? 'Disable' : 'Enable'}
136 |
137 | {!!TwoFactor && (
138 |
139 | this.openModal('SaveRecoveryCodes')}>
140 | Regenerate recovery codes
141 |
142 |
147 | i
148 |
149 |
150 | )}
151 |
152 |
153 |
2FA via Security Key
154 |
this.openModal('AddU2FKey')}
157 | disabled={!isSupported()}
158 | >
159 | {U2FKeys.length ? 'Add another key' : 'Enable'}
160 |
161 |
162 |
163 |
164 | );
165 | }
166 | }
167 |
168 | export default connect(
169 | 'settings',
170 | settingsAction
171 | )(TwoFactorSettings);
172 |
--------------------------------------------------------------------------------