Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 28 additions & 20 deletions src/commands/account/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,11 @@ export default class AccountAdd extends Command {
'api token is not creditable, please get your api token from https://console.liara.ir/API.',
);
}
const name = flags.account || (await this.promptName(user.email));
// Accounts without an email (such as phone sign-ups) use their phone or id.
const name =
flags.account ||
(await this.promptName(user.email || user.phone || user._id));

this.addNewAccountToConfig(currentAccounts, {
name,
...user,
Expand Down Expand Up @@ -101,8 +105,10 @@ export default class AccountAdd extends Command {
accountName && this.log(`> Current account is: ${accountName}`);
}
async checkIfExists(body: { email: string }, debug: DebugLogger) {
let data;

try {
const data = await this.got
data = await this.got
.post('v1/login/check-if-exists', {
json: body,
headers: { Authorization: undefined },
Expand All @@ -113,26 +119,28 @@ export default class AccountAdd extends Command {
socialCompleted: boolean;
twoFAEnabled: boolean;
}>();

if (!data.exists) {
this.log();
this.error(
`The email you entered isn’t registered.
To continue, please sign up at: https://console.liara.ir`,
);
}
if (!data.socialCompleted) {
this.log();
this.error(`No password is set for this account.
Set one here: https://console.liara.ir/settings/security
Then run 'liara login' or 'liara account:add' again.`);
}
return data;
} catch (error) {
debug(error);
this.error(`Please check your internet connection and try again.
If the issue persists, please submit a ticket at https://console.liara.ir/tickets for further assistance.`);
}

// Outside the try, which would replace these messages with the one above.
if (!data.exists) {
this.log();
this.error(
`The email you entered isn’t registered.
To continue, please sign up at: https://console.liara.ir`,
);
}

if (!data.socialCompleted) {
this.log();
this.error(`No password is set for this account.
Set one here: https://console.liara.ir/settings/security
Then run 'liara login' or 'liara account:add' again.`);
}
return data;
}

async login(body: {
Expand All @@ -151,7 +159,7 @@ If the issue persists, please submit a ticket at https://console.liara.ir/ticket
.json<IAccount>();

return {
email: data.email,
email: body.email,
api_token: data.api_token!,
fullname: data.fullname,
current: data.current,
Expand Down Expand Up @@ -263,10 +271,10 @@ Error: ${error.response.statusMessage} (${error.response.statusCode})
return password;
}

async getMe(flags: any): Promise<IAccount> {
async getMe(flags: any): Promise<IAccount & { _id: string; phone?: string }> {
const { user } = await this.got('v1/me', {
headers: { Authorization: `Bearer ${flags['api-token']}` },
}).json<{ user: IAccount }>();
}).json<{ user: IAccount & { _id: string; phone?: string } }>();

return user;
}
Expand Down
65 changes: 53 additions & 12 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import fs from 'fs-extra';
import chalk from 'chalk';
import open, { apps, AppName } from 'open';
import got from 'got';
import { Flags } from '@oclif/core';

import Command from '../base.js';
import Command, { IAccount } from '../base.js';
import AccountAdd from './account/add.js';
import AccountUse from './account/use.js';
import IBrowserLogin from '../types/browser-login.js';
import { createDebugLogger } from '../utils/output.js';
import { GLOBAL_CONF_PATH, GLOBAL_CONF_VERSION } from '../constants.js';
import {
GLOBAL_CONF_PATH,
GLOBAL_CONF_VERSION,
REGIONS_API_URL,
} from '../constants.js';
import ora from 'ora';

const CONSOLE_LOGIN_URL = 'https://console.liara.ir/login';
Expand Down Expand Up @@ -65,33 +70,69 @@ export default class Login extends Command {

let currentAccount;

// No interceptor hooks here: a rejected token must throw, not exit.
const api = got.extend({ prefixUrl: REGIONS_API_URL['iran'] });
const saved: string[] = [];

for (const account of accounts) {
if (!account.email) {
this.spinner.stop();
const skippedAccount = account?.email || account?.fullname;

await this.openConsoleLogin(flags.browser).catch((error) =>
debug(`${error.message}\n`),
if (!account?.token) {
this.spinner.stop();
this.warn(
`Skipped the account ${skippedAccount} because the console did not send a token for it.`,
);
continue;
}

let user;

try {
({ user } = await api('v1/me', {
headers: { Authorization: `Bearer ${account.token}` },
}).json<{ user: IAccount & { _id: string; phone?: string } }>());
} catch (error) {
if (error.response?.statusCode !== 401) {
throw error;
}

this.error(
`Your account${account.fullname ? ` (${account.fullname})` : ''} has no email address, which is required to log in with the CLI.
Please add an email to your account at ${CONSOLE_LOGIN_URL} and then run ${chalk.green('liara login')} again.`,
this.spinner.stop();
this.warn(
`Skipped the account ${skippedAccount} because its token was rejected. Log out of it at ${CONSOLE_LOGIN_URL}, log in again and then run ${chalk.green('liara login')} again.`,
);
continue;
}

const name = `${account.email.split('@')[0]}`;
// Accounts without an email, such as phone sign-ups, use their phone or id.
const name = user.email
? user.email.split('@')[0]
: user.phone || user._id;

if (account.current) {
currentAccount = name;
}

currentAccounts[name] = {
email: account.email,
email: user.email,
avatar: account.avatar,
api_token: account.token,
fullname: account.fullname,
current: false,
};
saved.push(name);
}

if (!saved.length) {
this.spinner.stop();

await this.openConsoleLogin(flags.browser).catch((error) =>
debug(`${error.message}\n`),
);

this.error(
`No valid account was received from the console.
Please log out and log in again at ${CONSOLE_LOGIN_URL} and then run ${chalk.green('liara login')} again.`,
);
}

fs.writeFileSync(
Expand All @@ -104,7 +145,7 @@ Please add an email to your account at ${CONSOLE_LOGIN_URL} and then run ${chalk

this.spinner.succeed('You have logged in successfully.');

currentAccount && (await AccountUse.run(['--account', currentAccount]));
await AccountUse.run(['--account', currentAccount || saved[0]]);

const { accountName } = await this.getCurrentAccount();

Expand Down
150 changes: 150 additions & 0 deletions test/auth/account-add.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { runCommand } from '@oclif/test';
import { expect } from 'chai';
import nock from 'nock';
import sinon from 'sinon';
import fs from 'fs-extra';
import inquirer from 'inquirer';
import path from 'path';
import os from 'node:os';
import AccountAdd from '../../src/commands/account/add.ts';

describe('account:add', async () => {
let fsStub: sinon.SinonStub;
let promptStub: sinon.SinonStub;

const mockMe = (user: object) =>
nock('https://api.liara.ir')
.get('/v1/me')
.matchHeader('authorization', 'Bearer token')
.reply(200, { user });

const mockCheckIfExists = (status: object = {}) =>
nock('https://api.liara.ir')
.post('/v1/login/check-if-exists', { email: 'jane@example.com' })
.reply(200, {
status: 'success',
exists: true,
socialCompleted: true,
twoFAEnabled: false,
...status,
});

const passwordLogin = () =>
runCommand([
'account:add',
'--email',
'jane@example.com',
'--password',
'secret',
]);

const expectedFile = (name: string, email: string | null) =>
JSON.stringify({
accounts: {
[name]: {
email,
api_token: 'token',
fullname: 'No Email',
avatar: '',
current: false,
},
},
version: '1',
});

beforeEach(() => {
nock.disableNetConnect();
fsStub = sinon.stub(fs, 'writeFileSync');
sinon
.stub(AccountAdd.prototype, 'readGlobalConfig')
.resolves({ version: '1', accounts: {} });
promptStub = sinon
.stub(inquirer, 'prompt')
.callsFake(async (q: any) => ({ name: q.default }));
});

afterEach(() => {
nock.cleanAll();
nock.enableNetConnect();
sinon.restore();
});

it('should name an api-token account without an email by its phone number', async () => {
mockMe({
_id: 'u1',
email: null,
phone: '09120000000',
fullname: 'No Email',
avatar: '',
});

const { error } = await runCommand(['account:add', '--api-token', 'token']);

expect(error).to.be.undefined;
expect(promptStub.firstCall.args[0].default).to.equal('09120000000');
expect(
fsStub.calledWithExactly(
path.join(os.homedir(), '.liara-auth.json'),
expectedFile('09120000000', null),
),
).to.be.true;
});

it('should default the api-token account name to the email local part', async () => {
mockMe({
_id: 'u1',
email: 'jane@example.com',
fullname: 'No Email',
avatar: '',
});

const { error } = await runCommand(['account:add', '--api-token', 'token']);

expect(error).to.be.undefined;
expect(promptStub.firstCall.args[0].default).to.equal('jane');
expect(
fsStub.calledWithExactly(
path.join(os.homedir(), '.liara-auth.json'),
expectedFile('jane', 'jane@example.com'),
),
).to.be.true;
});

it('should save the email of a password login, which the login response leaves out', async () => {
mockCheckIfExists();
nock('https://api.liara.ir')
.post('/v1/login', { email: 'jane@example.com', password: 'secret' })
.query({ expiresIn: '1y' })
.reply(200, { api_token: 'token', fullname: 'No Email', avatar: '' });

const { error } = await passwordLogin();

expect(error).to.be.undefined;
expect(
fsStub.calledWithExactly(
path.join(os.homedir(), '.liara-auth.json'),
expectedFile('jane', 'jane@example.com'),
),
).to.be.true;
});

it("should say when the email isn't registered", async () => {
mockCheckIfExists({ exists: false, socialCompleted: false });

const { error } = await passwordLogin();

expect(error?.message).to.contain(
'The email you entered isn’t registered.',
);
expect(fsStub.called).to.be.false;
});

it('should say when the account has no password, such as an OAuth sign-up', async () => {
mockCheckIfExists({ socialCompleted: false });

const { error } = await passwordLogin();

expect(error?.message).to.contain('No password is set for this account.');
expect(fsStub.called).to.be.false;
});
});
Loading
Loading