From 29109ec4373673d002e135becacde5403f2ac19f Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Wed, 28 Sep 2022 15:44:37 -0500 Subject: [PATCH] feat(breaking/login): remove `2fa` flag in favor of better prompts (#619) * test: minor refactors consolidating some const definitions, removing unnecessary snapshots and configstore handling * feat(login): deprecate 2fa flag in favor of better prompts * docs: remove 2fa guidance --- README.md | 6 --- .../cmds/__snapshots__/login.test.ts.snap | 7 --- __tests__/cmds/login.test.ts | 39 +++++++------- src/cmds/login.ts | 51 +++++++++++-------- 4 files changed, 48 insertions(+), 55 deletions(-) delete mode 100644 __tests__/cmds/__snapshots__/login.test.ts.snap diff --git a/README.md b/README.md index 20f550819..cb4a37332 100644 --- a/README.md +++ b/README.md @@ -40,12 +40,6 @@ For local CLI usage with a single project, you can authenticate `rdme` to your R rdme login ``` -If you have [two-factor authentication (2FA)](https://docs.readme.com/docs/two-factor-authentication) enabled on your account, you'll need to pass in the `--2fa` option: - -```sh -rdme login --2fa -``` - `rdme whoami` is also available to you to determine who you are logged in as, and to what project, as well as `rdme logout` for logging out of that account. ## Usage diff --git a/__tests__/cmds/__snapshots__/login.test.ts.snap b/__tests__/cmds/__snapshots__/login.test.ts.snap deleted file mode 100644 index 28e59eece..000000000 --- a/__tests__/cmds/__snapshots__/login.test.ts.snap +++ /dev/null @@ -1,7 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`rdme login should post to /login on the API 1`] = `"Successfully logged in as user@example.com to the subdomain project."`; - -exports[`rdme login should post to /login on the API if passing in project via opt 1`] = `"Successfully logged in as user@example.com to the subdomain project."`; - -exports[`rdme login should send 2fa token if provided 1`] = `"Successfully logged in as user@example.com to the subdomain project."`; diff --git a/__tests__/cmds/login.test.ts b/__tests__/cmds/login.test.ts index df3473ba0..7774b9baa 100644 --- a/__tests__/cmds/login.test.ts +++ b/__tests__/cmds/login.test.ts @@ -8,9 +8,11 @@ import getAPIMock from '../helpers/get-api-mock'; const cmd = new Command(); +const apiKey = 'abcdefg'; const email = 'user@example.com'; -const password = '123456'; +const password = 'password'; const project = 'subdomain'; +const token = '123456'; describe('rdme login', () => { beforeAll(() => nock.disableNetConnect()); @@ -33,34 +35,32 @@ describe('rdme login', () => { it('should post to /login on the API', async () => { prompts.inject([email, password, project]); - const apiKey = 'abcdefg'; const mock = getAPIMock().post('/api/v1/login').reply(200, { apiKey }); - await expect(cmd.run({})).resolves.toMatchSnapshot(); + await expect(cmd.run({})).resolves.toBe('Successfully logged in as user@example.com to the subdomain project.'); mock.done(); expect(configStore.get('apiKey')).toBe(apiKey); expect(configStore.get('email')).toBe(email); expect(configStore.get('project')).toBe(project); - configStore.clear(); }); it('should post to /login on the API if passing in project via opt', async () => { prompts.inject([email, password]); - const apiKey = 'abcdefg'; const mock = getAPIMock().post('/api/v1/login').reply(200, { apiKey }); - await expect(cmd.run({ project })).resolves.toMatchSnapshot(); + await expect(cmd.run({ project })).resolves.toBe( + 'Successfully logged in as user@example.com to the subdomain project.' + ); mock.done(); expect(configStore.get('apiKey')).toBe(apiKey); expect(configStore.get('email')).toBe(email); expect(configStore.get('project')).toBe(project); - configStore.clear(); }); it('should error if invalid credentials are given', async () => { @@ -78,8 +78,8 @@ describe('rdme login', () => { mock.done(); }); - it('should error if missing two factor token', async () => { - prompts.inject([email, password, project]); + it('should make additional prompt for token if login requires 2FA', async () => { + prompts.inject([email, password, project, token]); const errorResponse = { error: 'LOGIN_TWOFACTOR', message: 'You must provide a two-factor code', @@ -87,20 +87,19 @@ describe('rdme login', () => { help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', }; - const mock = getAPIMock().post('/api/v1/login', { email, password, project }).reply(401, errorResponse); - - await expect(cmd.run({})).rejects.toStrictEqual(new APIError(errorResponse)); - mock.done(); - }); - - it('should send 2fa token if provided', async () => { - const token = '123456'; - prompts.inject([email, password, project, token]); + const mock = getAPIMock() + .post('/api/v1/login', { email, password, project }) + .reply(401, errorResponse) + .post('/api/v1/login', { email, password, project, token }) + .reply(200, { apiKey }); - const mock = getAPIMock().post('/api/v1/login', { email, password, project, token }).reply(200, { apiKey: '123' }); + await expect(cmd.run({})).resolves.toBe('Successfully logged in as user@example.com to the subdomain project.'); - await expect(cmd.run({ '2fa': true })).resolves.toMatchSnapshot(); mock.done(); + + expect(configStore.get('apiKey')).toBe(apiKey); + expect(configStore.get('email')).toBe(email); + expect(configStore.get('project')).toBe(project); }); it('should error if trying to access a project that is not yours', async () => { diff --git a/src/cmds/login.ts b/src/cmds/login.ts index 179f2e1ec..883871f1c 100644 --- a/src/cmds/login.ts +++ b/src/cmds/login.ts @@ -8,16 +8,28 @@ import isEmail from 'validator/lib/isEmail'; import Command, { CommandCategories } from '../lib/baseCommand'; import configStore from '../lib/configstore'; import fetch, { handleRes } from '../lib/fetch'; +import { debug } from '../lib/logger'; import promptTerminal from '../lib/promptWrapper'; export type Options = { - '2fa'?: boolean; + project?: string; +}; + +type LoginBody = { email?: string; password?: string; project?: string; token?: string; }; +function loginFetch(body: LoginBody) { + return fetch(`${config.get('host')}/api/v1/login`, { + method: 'post', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + export default class LoginCommand extends Command { constructor() { super(); @@ -34,11 +46,6 @@ export default class LoginCommand extends Command { type: String, description: 'Project subdomain', }, - { - name: '2fa', - type: Boolean, - description: 'Prompt for a 2FA token', - }, ]; } @@ -47,7 +54,7 @@ export default class LoginCommand extends Command { prompts.override(opts); - const { email, password, project, token } = await promptTerminal([ + const { email, password, project } = await promptTerminal([ { type: 'text', name: 'email', @@ -68,11 +75,6 @@ export default class LoginCommand extends Command { message: 'What project are you logging into?', initial: configStore.get('project'), }, - { - type: opts['2fa'] ? 'text' : null, - name: 'token', - message: 'What is your 2FA token?', - }, ]); if (!project) { @@ -83,17 +85,22 @@ export default class LoginCommand extends Command { return Promise.reject(new Error('You must provide a valid email address.')); } - return fetch(`${config.get('host')}/api/v1/login`, { - method: 'post', - headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, - body: JSON.stringify({ - email, - password, - project, - token, - }), - }) + return loginFetch({ email, password, project }) .then(handleRes) + .catch(async err => { + // if the user's login requires 2FA, let's prompt them for the token! + if (err.code === 'LOGIN_TWOFACTOR') { + debug('2FA error response, prompting for 2FA code'); + const { token } = await promptTerminal({ + type: 'text', + name: 'token', + message: 'What is your 2FA token?', + }); + + return loginFetch({ email, password, project, token }).then(handleRes); + } + throw err; + }) .then(res => { configStore.set('apiKey', res.apiKey); configStore.set('email', email);