Skip to content

Commit

Permalink
feat(Authorization): support multiple accounts
Browse files Browse the repository at this point in the history
now all accounts are saved and can be choosen by
pressing on hostname / jira instance name in
header next to avatar

ISSUES CLOSED: #71
  • Loading branch information
ilya-lopukhin committed Mar 14, 2018
1 parent e3f4795 commit 106d30a
Show file tree
Hide file tree
Showing 14 changed files with 439 additions and 114 deletions.
1 change: 1 addition & 0 deletions app/actions/actionTypes/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export const LOGIN_OAUTH_REQUEST = 'auth/LOGIN_OAUTH_REQUEST';
export const LOGOUT_REQUEST = 'auth/LOGOUT_REQUEST';
export const ACCEPT_OAUTH = 'auth/ACCEPT_OAUTH';
export const DENY_OAUTH = 'auth/DENY_OAUTH';
export const SWITCH_ACCOUNT = 'auth/SWITCH_ACCOUNT';
13 changes: 12 additions & 1 deletion app/actions/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@ export const loginOAuthRequest = (host: string): AuthAction => ({
host,
});

export const logoutRequest = (): AuthAction => ({
export const logoutRequest = (payload: {
dontForget: boolean
} = { dontForget: false }): AuthAction => ({
type: actionTypes.LOGOUT_REQUEST,
payload,
});

export const acceptOAuth = (code: string): AuthAction => ({
Expand All @@ -32,3 +35,11 @@ export const acceptOAuth = (code: string): AuthAction => ({
export const denyOAuth = (): AuthAction => ({
type: actionTypes.DENY_OAUTH,
});

export const switchAccount = (payload: {|
host: string,
username: string,
|}) => ({
type: actionTypes.SWITCH_ACCOUNT,
payload,
});
48 changes: 45 additions & 3 deletions app/containers/Header/Header.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ import type {
import type {
Connector,
} from 'react-redux';
import Tag from '@atlaskit/tag';
import type {
User,
Dispatch,
} from 'types';

import Lozenge from '@atlaskit/lozenge';

import DropdownMenu, {
DropdownItemGroup,
Expand All @@ -43,6 +45,9 @@ import {
refreshWhite,
} from 'data/svg';
import config from 'config';
import EditorAddIcon from '@atlaskit/icon/glyph/editor/add';

import { transformValidHost } from '../../sagas/auth';

import {
HeaderContainer,
Expand All @@ -63,6 +68,10 @@ import {

type Props = {
userData: User,
accounts: Array<{|
host: string,
username: string,
|}>,
host: string,
updateAvailable: string,
updateFetching: boolean,
Expand All @@ -72,6 +81,7 @@ type Props = {

const Header: StatelessFunctionalComponent<Props> = ({
userData,
accounts,
host,
updateAvailable,
updateFetching,
Expand All @@ -88,9 +98,40 @@ const Header: StatelessFunctionalComponent<Props> = ({
<ProfileName>
{userData.displayName}
</ProfileName>
<ProfileTeam>
{host}
</ProfileTeam>
<DropdownMenu
triggerType="default"
position="right top"
trigger={
<ProfileTeam>
{host}
</ProfileTeam>
}
>
{accounts.map((ac) => {
const isActive = transformValidHost(ac.host).host === host &&
(ac.username === userData.emailAddress ||
ac.username === userData.key ||
ac.username === userData.name);
return (
<DropdownItem
key={`${ac.host}:${ac.username}`}
onClick={() => dispatch(authActions.switchAccount(ac))}
isDisabled={isActive}
elemAfter={isActive && <Lozenge appearance="success">Active</Lozenge>}
>
<Tag text={ac.host} color="teal" />
{ac.username}
</DropdownItem>
);
})}
<DropdownItem
onClick={() => dispatch(authActions.logoutRequest({ dontForget: true }))}
>
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
<EditorAddIcon /> Add account
</span>
</DropdownItem>
</DropdownMenu>
</ProfileInfo>
</ProfileContainer>

Expand Down Expand Up @@ -168,6 +209,7 @@ function mapStateToProps(state) {
return {
userData: getUserData(state),
host: getUiState('host')(state),
accounts: getUiState('accounts')(state),
updateAvailable: getUiState('updateAvailable')(state),
updateFetching: getUiState('updateFetching')(state),
issuesFetching: getResourceStatus(
Expand Down
2 changes: 2 additions & 0 deletions app/reducers/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
const initialState: UiState = {
initializeInProcess: false,
authorized: false,
accounts: [],
authFormStep: 1,
loginError: null,
loginRequestInProcess: false,
Expand Down Expand Up @@ -59,6 +60,7 @@ const initialState: UiState = {
confirmDeleteWorklog: false,
settings: false,
worklog: false,
accounts: false,
},

flags: [],
Expand Down
112 changes: 97 additions & 15 deletions app/sagas/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ import {

import {
setToStorage,
getFromStorage,
removeFromStorage,
} from './storage';
import {
initialConfigureApp,
} from './initializeApp';
import {
throwError,
notify,
} from './ui';
import createIpcChannel from './ipc';

Expand Down Expand Up @@ -99,6 +101,26 @@ export function* chronosBackendAuth({
}
}

function storeInKeytar(payload, host) {
ipcRenderer.sendSync(
'store-credentials',
{
...payload,
host: host.hostname,
},
);
}

function* saveAccount(payload: { host: string, username: string }): Generator<*, void, *> {
const { host } = payload;
let accounts = yield call(getFromStorage, 'accounts');
if (!accounts) accounts = [];
if (!accounts.find(ac => ac.host === host)) {
accounts.push(payload);
yield call(setToStorage, 'accounts', accounts);
}
}

export function* basicAuthLoginForm(): Generator<*, void, *> {
while (true) {
try {
Expand Down Expand Up @@ -141,30 +163,27 @@ export function* basicAuthLoginForm(): Generator<*, void, *> {
*/
yield call(
setToStorage,
'jira_credentials',
'last_used_account',
{
username: payload.username,
host: payload.host,
},
);
yield call(
initialConfigureApp,
saveAccount,
{
host: host.hostname,
protocol,
username: payload.username,
host: payload.host,
},
);
yield call(
(): void => {
ipcRenderer.sendSync(
'store-credentials',
{
...payload,
host: host.hostname,
},
);
initialConfigureApp,
{
host: host.hostname,
protocol,
},
);
yield call(storeInKeytar, payload, host);
yield put(uiActions.setUiState('loginRequestInProcess', false));
trackMixpanel('Jira login');
incrementMixpanel('Jira login', 1);
Expand Down Expand Up @@ -259,7 +278,7 @@ export function* oAuthLoginForm(): Generator<*, *, *> {

export function* logoutFlow(): Generator<*, *, *> {
while (true) {
yield take(actionTypes.LOGOUT_REQUEST);
const { payload: { dontForget } } = yield take(actionTypes.LOGOUT_REQUEST);
try {
const { getGlobal } = remote;
const { running, uploading } = getGlobal('sharedObj');
Expand All @@ -272,9 +291,9 @@ export function* logoutFlow(): Generator<*, *, *> {
// eslint-disable-next-line no-alert
window.alert('Currently app in process of saving worklog, wait few seconds please');
}
if (!running && !uploading) {
if (!running && !uploading && !dontForget) {
yield call(removeFromStorage, 'desktop_tracker_jwt');
yield call(removeFromStorage, 'jira_credentials');
yield call(removeFromStorage, 'last_used_account');
}
yield put({
type: actionTypes.__CLEAR_ALL_REDUCERS__,
Expand Down Expand Up @@ -312,6 +331,69 @@ function getOauthChannelListener(channel, type) {
};
}

export function* switchAccountFlow(): Generator<*, *, *> {
while (true) {
const { payload } = yield take(actionTypes.SWITCH_ACCOUNT);
try {
const { getGlobal } = remote;
const { running, uploading } = getGlobal('sharedObj');

if (running) {
// eslint-disable-next-line no-alert
window.alert('Tracking in progress, save worklog before logout!');
}
if (uploading) {
// eslint-disable-next-line no-alert
window.alert('Currently app in process of saving worklog, wait few seconds please');
}
if (!running && !uploading) {
const host = yield call(transformValidHost, payload.host);
const {
credentials,
error,
} = ipcRenderer.sendSync(
'get-credentials',
{
username: payload.username,
host: host.hostname,
},
);
if (error) {
Raven.captureMessage('keytar error!', {
level: 'error',
extra: {
error: error.err,
},
});
yield call(
throwError,
error.err,
);
if (error.platform === 'linux') {
yield fork(
notify,
{
type: 'libSecretError',
autoDelete: false,
},
);
}
} else {
yield put({
type: actionTypes.__CLEAR_ALL_REDUCERS__,
});
yield put(uiActions.setUiState('initializeInProcess', true));
yield put(authActions.loginRequest({ ...payload, password: credentials.password }));
}
}
trackMixpanel('SwitchAccounts');
incrementMixpanel('SwitchAccounts', 1);
} catch (err) {
yield call(throwError, err);
}
}
}

export function* createIpcAuthListeners(): Generator<*, *, *> {
const oAuthAcceptedChannel = yield call(createIpcChannel, 'oauth-accepted');
const oAuthDeniedChannel = yield call(createIpcChannel, 'oauth-denied');
Expand Down
1 change: 1 addition & 0 deletions app/sagas/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export default function* rootSaga(): Generator<*, void, *> {
fork(authSagas.basicAuthLoginForm),
fork(authSagas.oAuthLoginForm),
fork(authSagas.logoutFlow),
fork(authSagas.switchAccountFlow),

// projects
fork(projectSagas.watchFetchProjectStatusesRequest),
Expand Down
7 changes: 6 additions & 1 deletion app/sagas/initializeApp.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ export function* initialConfigureApp({
yield call(initializeMixpanel);
yield call(identifyInSentryAndMixpanel, host, userData);

let accounts = yield call(getFromStorage, 'accounts');
if (!accounts) accounts = [];
yield put(uiActions.setUiState('accounts', accounts));

const issuesSourceId: Id | null = yield call(getFromStorage, 'issuesSourceId');
const issuesSourceType = yield call(getFromStorage, 'issuesSourceType');
const issuesSprintId: Id | null = yield call(getFromStorage, 'issuesSprintId');
Expand Down Expand Up @@ -153,6 +157,7 @@ export function* initialConfigureApp({
refetchFilterIssuesMarker: false,
},
}));
yield put(uiActions.setUiState('initializeInProcess', false));
/*
const isPaidChronosUser = yield select(getIsPaidUser);
Expand All @@ -166,7 +171,7 @@ export function* initialConfigureApp({
function* getInitializeAppData(): Generator<*, *, *> {
const basicAuthCredentials = yield call(
getFromStorage,
'jira_credentials',
'last_used_account',
);
const basicAuthDataExist =
basicAuthCredentials !== null &&
Expand Down
1 change: 0 additions & 1 deletion app/styles/index.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// @flow
import styled from 'styled-components2';


export const AppWrapper = styled.div`
height: 100%;
overflow: hidden;
Expand Down
3 changes: 3 additions & 0 deletions app/types/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ export type AuthAction =
|} |
{|
type: typeof actionTypes.LOGOUT_REQUEST,
payload: {
dontForget: boolean,
}
|} |
{|
type: typeof actionTypes.ACCEPT_OAUTH,
Expand Down
1 change: 1 addition & 0 deletions app/types/profile.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type User = {
items: Array<any>,
},
key: string,
name: string,
locale?: string,
self: string,
timeZone: string,
Expand Down
2 changes: 2 additions & 0 deletions app/types/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export type RemainingEstimate = 'auto' | 'new' | 'manual' | 'leave';
export type UiState = {|
initializeInProcess: boolean,
authorized: boolean,
accounts: Array<{ host: string, username: string }>,
authFormStep: number,
loginError: null | string,
loginRequestInProcess: boolean,
Expand Down Expand Up @@ -101,6 +102,7 @@ export type UiState = {|
confirmDeleteWorklog: boolean,
settings: boolean,
worklog: boolean,
accounts: boolean,
|},

flags: Array<any>,
Expand Down
Loading

0 comments on commit 106d30a

Please sign in to comment.