import { PropertyValue } from "~/models/search-bar";
-export const formatDate = (isoDate?: string | null) => {
+export const formatDate = (isoDate?: string | null, utc: boolean = false) => {
if (isoDate) {
const date = new Date(isoDate);
- const text = date.toLocaleString();
+ let text: string;
+ if (utc) {
+ text = date.toUTCString();
+ }
+ else {
+ text = date.toLocaleString();
+ }
return text === 'Invalid Date' ? "(none)" : text;
}
return "(none)";
description: string;
properties: any;
writeableBy: string[];
+ ensure_unique_name: boolean;
}
export enum GroupClass {
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export enum LinkAccountStatus {
+ SUCCESS,
+ CANCELLED,
+ FAILED
+}
+
+export enum LinkAccountType {
+ ADD_OTHER_LOGIN,
+ ADD_LOCAL_TO_REMOTE,
+ ACCESS_OTHER_ACCOUNT,
+ ACCESS_OTHER_REMOTE_ACCOUNT
+}
+
+export interface AccountToLink {
+ type: LinkAccountType;
+ userUuid: string;
+ token: string;
+}
trashAt: "",
uuid: "",
writeableBy: [],
+ ensure_unique_name: true,
...data
});
const computeNodesMatch = Routes.matchComputeNodesRoute(pathname);
const apiClientAuthorizationsMatch = Routes.matchApiClientAuthorizationsRoute(pathname);
const myAccountMatch = Routes.matchMyAccountRoute(pathname);
+ const linkAccountMatch = Routes.matchLinkAccountRoute(pathname);
const userMatch = Routes.matchUsersRoute(pathname);
const groupsMatch = Routes.matchGroupsRoute(pathname);
const groupDetailsMatch = Routes.matchGroupDetailsRoute(pathname);
store.dispatch(WorkbenchActions.loadApiClientAuthorizations);
} else if (myAccountMatch) {
store.dispatch(WorkbenchActions.loadMyAccount);
+ } else if (linkAccountMatch) {
+ store.dispatch(WorkbenchActions.loadLinkAccount);
} else if (userMatch) {
store.dispatch(WorkbenchActions.loadUsers);
} else if (groupsMatch) {
SSH_KEYS_USER: `/ssh-keys-user`,
SITE_MANAGER: `/site-manager`,
MY_ACCOUNT: '/my-account',
+ LINK_ACCOUNT: '/link_account',
KEEP_SERVICES: `/keep-services`,
COMPUTE_NODES: `/nodes`,
USERS: '/users',
export const matchMyAccountRoute = (route: string) =>
matchPath(route, { path: Routes.MY_ACCOUNT });
+export const matchLinkAccountRoute = (route: string) =>
+ matchPath(route, { path: Routes.LINK_ACCOUNT });
+
export const matchKeepServicesRoute = (route: string) =>
matchPath(route, { path: Routes.KEEP_SERVICES });
+export const matchTokenRoute = (route: string) =>
+ matchPath(route, { path: Routes.TOKEN });
+
+export const matchFedTokenRoute = (route: string) =>
+ matchPath(route, {path: Routes.FED_LOGIN});
+
export const matchUsersRoute = (route: string) =>
matchPath(route, { path: Routes.USERS });
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { AxiosInstance } from "axios";
+import { ApiActions } from "~/services/api/api-actions";
+import { AccountToLink, LinkAccountStatus } from "~/models/link-account";
+import { CommonService } from "~/services/common-service/common-service";
+
+export const USER_LINK_ACCOUNT_KEY = 'accountToLink';
+export const ACCOUNT_LINK_STATUS_KEY = 'accountLinkStatus';
+
+export class LinkAccountService {
+
+ constructor(
+ protected serverApi: AxiosInstance,
+ protected actions: ApiActions) { }
+
+ public saveAccountToLink(account: AccountToLink) {
+ sessionStorage.setItem(USER_LINK_ACCOUNT_KEY, JSON.stringify(account));
+ }
+
+ public removeAccountToLink() {
+ sessionStorage.removeItem(USER_LINK_ACCOUNT_KEY);
+ }
+
+ public getAccountToLink() {
+ const data = sessionStorage.getItem(USER_LINK_ACCOUNT_KEY);
+ return data ? JSON.parse(data) as AccountToLink : undefined;
+ }
+
+ public saveLinkOpStatus(status: LinkAccountStatus) {
+ sessionStorage.setItem(ACCOUNT_LINK_STATUS_KEY, JSON.stringify(status));
+ }
+
+ public removeLinkOpStatus() {
+ sessionStorage.removeItem(ACCOUNT_LINK_STATUS_KEY);
+ }
+
+ public getLinkOpStatus() {
+ const data = sessionStorage.getItem(ACCOUNT_LINK_STATUS_KEY);
+ return data ? JSON.parse(data) as LinkAccountStatus : undefined;
+ }
+
+ public linkAccounts(newUserToken: string, newGroupUuid: string) {
+ const params = {
+ new_user_token: newUserToken,
+ new_owner_uuid: newGroupUuid,
+ redirect_to_new_user: true
+ };
+ return CommonService.defaultResponse(
+ this.serverApi.post('/users/merge/', params),
+ this.actions,
+ false
+ );
+ }
+}
\ No newline at end of file
import { VocabularyService } from '~/services/vocabulary-service/vocabulary-service';
import { NodeService } from '~/services/node-service/node-service';
import { FileViewersConfigService } from '~/services/file-viewers-config-service/file-viewers-config-service';
+import { LinkAccountService } from "./link-account-service/link-account-service";
export type ServiceRepository = ReturnType<typeof createServices>;
const userService = new UserService(apiClient, actions);
const virtualMachineService = new VirtualMachinesService(apiClient, actions);
const workflowService = new WorkflowService(apiClient, actions);
+ const linkAccountService = new LinkAccountService(apiClient, actions);
const ancestorsService = new AncestorService(groupsService, userService);
const authService = new AuthService(apiClient, config.rootUrl, actions);
webdavClient,
workflowService,
vocabularyService,
+ linkAccountService
};
};
import createBrowserHistory from "history/createBrowserHistory";
import { Config, mockConfig } from '~/common/config';
import { ApiActions } from "~/services/api/api-actions";
+import { ACCOUNT_LINK_STATUS_KEY} from '~/services/link-account-service/link-account-service';
describe('auth-actions', () => {
let reducer: (state: AuthState | undefined, action: AuthAction) => any;
it('should initialise state with user and api token from local storage', () => {
+ // Only test the case when a link account operation is not being cancelled
+ sessionStorage.setItem(ACCOUNT_LINK_STATUS_KEY, "0");
localStorage.setItem(API_TOKEN_KEY, "token");
localStorage.setItem(USER_EMAIL_KEY, "test@test.com");
localStorage.setItem(USER_FIRST_NAME_KEY, "John");
sshKeys: [],
homeCluster: "zzzzz",
localCluster: "zzzzz",
+ remoteHostsConfig: {},
remoteHosts: {
zzzzz: "zzzzz.arvadosapi.com",
xc59z: "xc59z.arvadosapi.com"
import { RootState } from "../store";
import { ServiceRepository } from "~/services/services";
import { SshKeyResource } from '~/models/ssh-key';
-import { User } from "~/models/user";
+import { User, UserResource } from "~/models/user";
import { Session } from "~/models/session";
import { getDiscoveryURL, Config } from '~/common/config';
import { initSessions } from "~/store/auth/auth-action-session";
+import { cancelLinking } from '~/store/link-account-panel/link-account-panel-actions';
+import { matchTokenRoute, matchFedTokenRoute } from '~/routes/routes';
import Axios from "axios";
import { AxiosError } from "axios";
export const authActions = unionize({
SAVE_API_TOKEN: ofType<string>(),
+ SAVE_USER: ofType<UserResource>(),
LOGIN: {},
LOGOUT: {},
CONFIG: ofType<{ config: Config }>(),
REMOTE_CLUSTER_CONFIG: ofType<{ config: Config }>(),
});
-function setAuthorizationHeader(services: ServiceRepository, token: string) {
+export function setAuthorizationHeader(services: ServiceRepository, token: string) {
services.apiClient.defaults.headers.common = {
Authorization: `OAuth2 ${token}`
};
}
export const initAuth = (config: Config) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+ // Cancel any link account ops in progress unless the user has
+ // just logged in or there has been a successful link operation
+ const data = services.linkAccountService.getLinkOpStatus();
+ if (!matchTokenRoute(location.pathname) && (!matchFedTokenRoute(location.pathname)) && data === undefined) {
+ dispatch<any>(cancelLinking()).then(() => {
+ dispatch<any>(init(config));
+ });
+ }
+ else {
+ dispatch<any>(init(config));
+ }
+};
+
+const init = (config: Config) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
const user = services.authService.getUser();
const token = services.authService.getApiToken();
const homeCluster = services.authService.getHomeCluster();
dispatch(authActions.SAVE_API_TOKEN(token));
};
+export const saveUser = (user: UserResource) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+ services.authService.saveUser(user);
+ dispatch(authActions.SAVE_USER(user));
+};
+
export const login = (uuidPrefix: string, homeCluster: string, remoteHosts: { [key: string]: string }) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
services.authService.login(uuidPrefix, homeCluster, remoteHosts);
dispatch(authActions.LOGIN());
};
-export const logout = () => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+export const logout = (deleteLinkData: boolean = false) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+ if (deleteLinkData) {
+ services.linkAccountService.removeAccountToLink();
+ }
services.authService.removeApiToken();
services.authService.removeUser();
removeAuthorizationHeader(services.apiClient);
// SPDX-License-Identifier: AGPL-3.0
import { authActions, AuthAction } from "./auth-action";
-import { User } from "~/models/user";
+import { User, UserResource } from "~/models/user";
import { ServiceRepository } from "~/services/services";
import { SshKeyResource } from '~/models/ssh-key';
import { Session } from "~/models/session";
SAVE_API_TOKEN: (token: string) => {
return { ...state, apiToken: token };
},
+ SAVE_USER: (user: UserResource) => {
+ return { ...state, user};
+ },
CONFIG: ({ config }) => {
return {
...state,
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { RootState } from "~/store/store";
+import { ServiceRepository } from "~/services/services";
+import { setBreadcrumbs } from "~/store/breadcrumbs/breadcrumbs-actions";
+import { snackbarActions, SnackbarKind } from "~/store/snackbar/snackbar-actions";
+import { LinkAccountType, AccountToLink, LinkAccountStatus } from "~/models/link-account";
+import { saveApiToken, saveUser } from "~/store/auth/auth-action";
+import { unionize, ofType, UnionOf } from '~/common/unionize';
+import { UserResource } from "~/models/user";
+import { GroupResource } from "~/models/group";
+import { LinkAccountPanelError, OriginatingUser } from "./link-account-panel-reducer";
+import { login, logout, setAuthorizationHeader } from "~/store/auth/auth-action";
+import { progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions";
+import { WORKBENCH_LOADING_SCREEN } from '~/store/workbench/workbench-actions';
+
+export const linkAccountPanelActions = unionize({
+ LINK_INIT: ofType<{
+ targetUser: UserResource | undefined }>(),
+ LINK_LOAD: ofType<{
+ originatingUser: OriginatingUser | undefined,
+ targetUser: UserResource | undefined,
+ targetUserToken: string | undefined,
+ userToLink: UserResource | undefined,
+ userToLinkToken: string | undefined }>(),
+ LINK_INVALID: ofType<{
+ originatingUser: OriginatingUser | undefined,
+ targetUser: UserResource | undefined,
+ userToLink: UserResource | undefined,
+ error: LinkAccountPanelError }>(),
+ SET_SELECTED_CLUSTER: ofType<{
+ selectedCluster: string }>(),
+ SET_IS_PROCESSING: ofType<{
+ isProcessing: boolean}>(),
+ HAS_SESSION_DATA: {}
+});
+
+export type LinkAccountPanelAction = UnionOf<typeof linkAccountPanelActions>;
+
+function validateLink(userToLink: UserResource, targetUser: UserResource) {
+ if (userToLink.uuid === targetUser.uuid) {
+ return LinkAccountPanelError.SAME_USER;
+ }
+ else if (userToLink.isAdmin && !targetUser.isAdmin) {
+ return LinkAccountPanelError.NON_ADMIN;
+ }
+ else if (!targetUser.isActive) {
+ return LinkAccountPanelError.INACTIVE;
+ }
+ return LinkAccountPanelError.NONE;
+}
+
+export const checkForLinkStatus = () =>
+ (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+ const status = services.linkAccountService.getLinkOpStatus();
+ if (status !== undefined) {
+ let msg: string;
+ let msgKind: SnackbarKind;
+ if (status.valueOf() === LinkAccountStatus.CANCELLED) {
+ msg = "Account link cancelled!", msgKind = SnackbarKind.INFO;
+ }
+ else if (status.valueOf() === LinkAccountStatus.FAILED) {
+ msg = "Account link failed!", msgKind = SnackbarKind.ERROR;
+ }
+ else if (status.valueOf() === LinkAccountStatus.SUCCESS) {
+ msg = "Account link success!", msgKind = SnackbarKind.SUCCESS;
+ }
+ else {
+ msg = "Unknown Error!", msgKind = SnackbarKind.ERROR;
+ }
+ dispatch(snackbarActions.OPEN_SNACKBAR({ message: msg, kind: msgKind, hideDuration: 3000 }));
+ services.linkAccountService.removeLinkOpStatus();
+ }
+ };
+
+export const switchUser = (user: UserResource, token: string) =>
+ (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+ dispatch(saveUser(user));
+ dispatch(saveApiToken(token));
+ };
+
+export const linkFailed = () =>
+ (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+ // If the link fails, switch to the user account that originated the link operation
+ const linkState = getState().linkAccountPanel;
+ if (linkState.userToLink && linkState.userToLinkToken && linkState.targetUser && linkState.targetUserToken) {
+ if (linkState.originatingUser === OriginatingUser.TARGET_USER) {
+ dispatch(switchUser(linkState.targetUser, linkState.targetUserToken));
+ }
+ else if ((linkState.originatingUser === OriginatingUser.USER_TO_LINK)) {
+ dispatch(switchUser(linkState.userToLink, linkState.userToLinkToken));
+ }
+ }
+ services.linkAccountService.removeAccountToLink();
+ services.linkAccountService.saveLinkOpStatus(LinkAccountStatus.FAILED);
+ location.reload();
+ };
+
+export const loadLinkAccountPanel = () =>
+ async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+ try {
+ // If there are remote hosts, set the initial selected cluster by getting the first cluster that isn't the local cluster
+ if (getState().linkAccountPanel.selectedCluster === undefined) {
+ const localCluster = getState().auth.localCluster;
+ let selectedCluster = localCluster;
+ for (const key in getState().auth.remoteHosts) {
+ if (key !== localCluster) {
+ selectedCluster = key;
+ break;
+ }
+ }
+ dispatch(linkAccountPanelActions.SET_SELECTED_CLUSTER({ selectedCluster }));
+ }
+
+ // First check if an account link operation has completed
+ dispatch(checkForLinkStatus());
+
+ // Continue loading the link account panel
+ dispatch(setBreadcrumbs([{ label: 'Link account'}]));
+ const curUser = getState().auth.user;
+ const curToken = getState().auth.apiToken;
+ if (curUser && curToken) {
+
+ // If there is link account session data, then the user has logged in a second time
+ const linkAccountData = services.linkAccountService.getAccountToLink();
+ if (linkAccountData) {
+
+ dispatch(linkAccountPanelActions.SET_IS_PROCESSING({ isProcessing: true }));
+ const curUserResource = await services.userService.get(curUser.uuid);
+
+ // Use the token of the user we are getting data for. This avoids any admin/non-admin permissions
+ // issues since a user will always be able to query the api server for their own user data.
+ setAuthorizationHeader(services, linkAccountData.token);
+ const savedUserResource = await services.userService.get(linkAccountData.userUuid);
+ setAuthorizationHeader(services, curToken);
+
+ let params: any;
+ if (linkAccountData.type === LinkAccountType.ACCESS_OTHER_ACCOUNT || linkAccountData.type === LinkAccountType.ACCESS_OTHER_REMOTE_ACCOUNT) {
+ params = {
+ originatingUser: OriginatingUser.USER_TO_LINK,
+ targetUser: curUserResource,
+ targetUserToken: curToken,
+ userToLink: savedUserResource,
+ userToLinkToken: linkAccountData.token
+ };
+ }
+ else if (linkAccountData.type === LinkAccountType.ADD_OTHER_LOGIN || linkAccountData.type === LinkAccountType.ADD_LOCAL_TO_REMOTE) {
+ params = {
+ originatingUser: OriginatingUser.TARGET_USER,
+ targetUser: savedUserResource,
+ targetUserToken: linkAccountData.token,
+ userToLink: curUserResource,
+ userToLinkToken: curToken
+ };
+ }
+ else {
+ throw new Error("Unknown link account type");
+ }
+
+ dispatch(switchUser(params.targetUser, params.targetUserToken));
+ const error = validateLink(params.userToLink, params.targetUser);
+ if (error === LinkAccountPanelError.NONE) {
+ dispatch(linkAccountPanelActions.LINK_LOAD(params));
+ }
+ else {
+ dispatch(linkAccountPanelActions.LINK_INVALID({
+ originatingUser: params.originatingUser,
+ targetUser: params.targetUser,
+ userToLink: params.userToLink,
+ error}));
+ return;
+ }
+ }
+ else {
+ // If there is no link account session data, set the state to invoke the initial UI
+ const curUserResource = await services.userService.get(curUser.uuid);
+ dispatch(linkAccountPanelActions.LINK_INIT({ targetUser: curUserResource }));
+ return;
+ }
+ }
+ }
+ catch (e) {
+ dispatch(linkFailed());
+ }
+ finally {
+ dispatch(linkAccountPanelActions.SET_IS_PROCESSING({ isProcessing: false }));
+ }
+ };
+
+export const startLinking = (t: LinkAccountType) =>
+ (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+ const accountToLink = {type: t, userUuid: services.authService.getUuid(), token: services.authService.getApiToken()} as AccountToLink;
+ services.linkAccountService.saveAccountToLink(accountToLink);
+
+ const auth = getState().auth;
+ const isLocalUser = auth.user!.uuid.substring(0,5) === auth.localCluster;
+ let homeCluster = auth.localCluster;
+ if (isLocalUser && t === LinkAccountType.ACCESS_OTHER_REMOTE_ACCOUNT) {
+ homeCluster = getState().linkAccountPanel.selectedCluster!;
+ }
+
+ dispatch(logout());
+ dispatch(login(auth.localCluster, homeCluster, auth.remoteHosts));
+ };
+
+export const getAccountLinkData = () =>
+ (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+ return services.linkAccountService.getAccountToLink();
+ };
+
+export const cancelLinking = (reload: boolean = false) =>
+ async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+ let user: UserResource | undefined;
+ try {
+ // When linking is cancelled switch to the originating user (i.e. the user saved in session data)
+ dispatch(progressIndicatorActions.START_WORKING(WORKBENCH_LOADING_SCREEN));
+ const linkAccountData = services.linkAccountService.getAccountToLink();
+ if (linkAccountData) {
+ services.linkAccountService.removeAccountToLink();
+ setAuthorizationHeader(services, linkAccountData.token);
+ user = await services.userService.get(linkAccountData.userUuid);
+ dispatch(switchUser(user, linkAccountData.token));
+ services.linkAccountService.saveLinkOpStatus(LinkAccountStatus.CANCELLED);
+ }
+ }
+ finally {
+ if (reload) {
+ location.reload();
+ }
+ else {
+ dispatch(progressIndicatorActions.STOP_WORKING(WORKBENCH_LOADING_SCREEN));
+ }
+ }
+ };
+
+export const linkAccount = () =>
+ async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+ const linkState = getState().linkAccountPanel;
+ if (linkState.userToLink && linkState.userToLinkToken && linkState.targetUser && linkState.targetUserToken) {
+
+ // First create a project owned by the target user
+ const projectName = `Migrated from ${linkState.userToLink.email} (${linkState.userToLink.uuid})`;
+ let newGroup: GroupResource;
+ try {
+ newGroup = await services.projectService.create({
+ name: projectName,
+ ensure_unique_name: true
+ });
+ }
+ catch (e) {
+ dispatch(linkFailed());
+ throw e;
+ }
+
+ try {
+ // The merge api links the user sending the request into the user
+ // specified in the request, so change the authorization header accordingly
+ setAuthorizationHeader(services, linkState.userToLinkToken);
+ await services.linkAccountService.linkAccounts(linkState.targetUserToken, newGroup.uuid);
+ dispatch(switchUser(linkState.targetUser, linkState.targetUserToken));
+ services.linkAccountService.removeAccountToLink();
+ services.linkAccountService.saveLinkOpStatus(LinkAccountStatus.SUCCESS);
+ location.reload();
+ }
+ catch(e) {
+ // If the link operation fails, delete the previously made project
+ try {
+ setAuthorizationHeader(services, linkState.targetUserToken);
+ await services.projectService.delete(newGroup.uuid);
+ }
+ finally {
+ dispatch(linkFailed());
+ }
+ throw e;
+ }
+ }
+ };
\ No newline at end of file
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { linkAccountPanelReducer, LinkAccountPanelError, LinkAccountPanelStatus, OriginatingUser } from "~/store/link-account-panel/link-account-panel-reducer";
+import { linkAccountPanelActions } from "~/store/link-account-panel/link-account-panel-actions";
+import { UserResource } from "~/models/user";
+
+describe('link-account-panel-reducer', () => {
+ const initialState = undefined;
+
+ it('handles initial link account state', () => {
+ const targetUser = { } as any;
+ targetUser.username = "targetUser";
+
+ const state = linkAccountPanelReducer(initialState, linkAccountPanelActions.LINK_INIT({targetUser}));
+ expect(state).toEqual({
+ targetUser,
+ isProcessing: false,
+ selectedCluster: undefined,
+ targetUserToken: undefined,
+ userToLink: undefined,
+ userToLinkToken: undefined,
+ originatingUser: OriginatingUser.NONE,
+ error: LinkAccountPanelError.NONE,
+ status: LinkAccountPanelStatus.INITIAL
+ });
+ });
+
+ it('handles loaded link account state', () => {
+ const targetUser = { } as any;
+ targetUser.username = "targetUser";
+ const targetUserToken = "targettoken";
+
+ const userToLink = { } as any;
+ userToLink.username = "userToLink";
+ const userToLinkToken = "usertoken";
+
+ const originatingUser = OriginatingUser.TARGET_USER;
+
+ const state = linkAccountPanelReducer(initialState, linkAccountPanelActions.LINK_LOAD({
+ originatingUser, targetUser, targetUserToken, userToLink, userToLinkToken}));
+ expect(state).toEqual({
+ targetUser,
+ targetUserToken,
+ isProcessing: false,
+ selectedCluster: undefined,
+ userToLink,
+ userToLinkToken,
+ originatingUser: OriginatingUser.TARGET_USER,
+ error: LinkAccountPanelError.NONE,
+ status: LinkAccountPanelStatus.LINKING
+ });
+ });
+
+ it('handles loaded invalid account state', () => {
+ const targetUser = { } as any;
+ targetUser.username = "targetUser";
+
+ const userToLink = { } as any;
+ userToLink.username = "userToLink";
+
+ const originatingUser = OriginatingUser.TARGET_USER;
+ const error = LinkAccountPanelError.NON_ADMIN;
+
+ const state = linkAccountPanelReducer(initialState, linkAccountPanelActions.LINK_INVALID({targetUser, userToLink, originatingUser, error}));
+ expect(state).toEqual({
+ targetUser,
+ targetUserToken: undefined,
+ isProcessing: false,
+ selectedCluster: undefined,
+ userToLink,
+ userToLinkToken: undefined,
+ originatingUser: OriginatingUser.TARGET_USER,
+ error: LinkAccountPanelError.NON_ADMIN,
+ status: LinkAccountPanelStatus.ERROR
+ });
+ });
+});
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { linkAccountPanelActions, LinkAccountPanelAction } from "~/store/link-account-panel/link-account-panel-actions";
+import { UserResource } from "~/models/user";
+
+export enum LinkAccountPanelStatus {
+ NONE,
+ INITIAL,
+ HAS_SESSION_DATA,
+ LINKING,
+ ERROR
+}
+
+export enum LinkAccountPanelError {
+ NONE,
+ INACTIVE,
+ NON_ADMIN,
+ SAME_USER
+}
+
+export enum OriginatingUser {
+ NONE,
+ TARGET_USER,
+ USER_TO_LINK
+}
+
+export interface LinkAccountPanelState {
+ selectedCluster: string | undefined;
+ originatingUser: OriginatingUser | undefined;
+ targetUser: UserResource | undefined;
+ targetUserToken: string | undefined;
+ userToLink: UserResource | undefined;
+ userToLinkToken: string | undefined;
+ status: LinkAccountPanelStatus;
+ error: LinkAccountPanelError;
+ isProcessing: boolean;
+}
+
+const initialState = {
+ selectedCluster: undefined,
+ originatingUser: OriginatingUser.NONE,
+ targetUser: undefined,
+ targetUserToken: undefined,
+ userToLink: undefined,
+ userToLinkToken: undefined,
+ isProcessing: false,
+ status: LinkAccountPanelStatus.NONE,
+ error: LinkAccountPanelError.NONE
+};
+
+export const linkAccountPanelReducer = (state: LinkAccountPanelState = initialState, action: LinkAccountPanelAction) =>
+ linkAccountPanelActions.match(action, {
+ default: () => state,
+ LINK_INIT: ({ targetUser }) => ({
+ ...state,
+ targetUser, targetUserToken: undefined,
+ userToLink: undefined, userToLinkToken: undefined,
+ status: LinkAccountPanelStatus.INITIAL, error: LinkAccountPanelError.NONE, originatingUser: OriginatingUser.NONE
+ }),
+ LINK_LOAD: ({ originatingUser, userToLink, targetUser, targetUserToken, userToLinkToken}) => ({
+ ...state,
+ originatingUser,
+ targetUser, targetUserToken,
+ userToLink, userToLinkToken,
+ status: LinkAccountPanelStatus.LINKING, error: LinkAccountPanelError.NONE
+ }),
+ LINK_INVALID: ({ originatingUser, targetUser, userToLink, error }) => ({
+ ...state,
+ originatingUser,
+ targetUser, targetUserToken: undefined,
+ userToLink, userToLinkToken: undefined,
+ error, status: LinkAccountPanelStatus.ERROR
+ }),
+ SET_SELECTED_CLUSTER: ({ selectedCluster }) => ({
+ ...state, selectedCluster
+ }),
+ SET_IS_PROCESSING: ({ isProcessing }) =>({
+ ...state,
+ isProcessing
+ }),
+ HAS_SESSION_DATA: () => ({
+ ...state, status: LinkAccountPanelStatus.HAS_SESSION_DATA
+ })
+ });
\ No newline at end of file
export const navigateToMyAccount = push(Routes.MY_ACCOUNT);
+export const navigateToLinkAccount = push(Routes.LINK_ACCOUNT);
+
export const navigateToKeepServices = push(Routes.KEEP_SERVICES);
export const navigateToComputeNodes = push(Routes.COMPUTE_NODES);
import { PublicFavoritesMiddlewareService } from '~/store/public-favorites-panel/public-favorites-middleware-service';
import { PUBLIC_FAVORITE_PANEL_ID } from '~/store/public-favorites-panel/public-favorites-action';
import { publicFavoritesReducer } from '~/store/public-favorites/public-favorites-reducer';
+import { linkAccountPanelReducer } from './link-account-panel/link-account-panel-reducer';
import { CollectionsWithSameContentAddressMiddlewareService } from '~/store/collections-content-address-panel/collections-content-address-middleware-service';
import { COLLECTIONS_CONTENT_ADDRESS_PANEL_ID } from '~/store/collections-content-address-panel/collections-content-address-panel-actions';
import { ownerNameReducer } from '~/store/owner-name/owner-name-reducer';
searchBar: searchBarReducer,
virtualMachines: virtualMachinesReducer,
repositories: repositoriesReducer,
- keepServices: keepServicesReducer
+ keepServices: keepServicesReducer,
+ linkAccountPanel: linkAccountPanelReducer
});
import { loadWorkflowPanel, workflowPanelActions } from '~/store/workflow-panel/workflow-panel-actions';
import { loadSshKeysPanel } from '~/store/auth/auth-action-ssh';
import { loadMyAccountPanel } from '~/store/my-account/my-account-panel-actions';
+import { loadLinkAccountPanel, linkAccountPanelActions } from '~/store/link-account-panel/link-account-panel-actions';
import { loadSiteManagerPanel } from '~/store/auth/auth-action-session';
import { workflowPanelColumns } from '~/views/workflow-panel/workflow-panel-view';
import { progressIndicatorActions } from '~/store/progress-indicator/progress-indicator-actions';
};
export const loadWorkbench = () =>
- async (dispatch: Dispatch, getState: () => RootState) => {
+ async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
dispatch(progressIndicatorActions.START_WORKING(WORKBENCH_LOADING_SCREEN));
const { auth, router } = getState();
const { user } = auth;
dispatch(apiClientAuthorizationsActions.SET_COLUMNS({ columns: apiClientAuthorizationPanelColumns }));
dispatch(collectionsContentAddressActions.SET_COLUMNS({ columns: collectionContentAddressPanelColumns }));
+ if (services.linkAccountService.getAccountToLink()) {
+ dispatch(linkAccountPanelActions.HAS_SESSION_DATA());
+ }
+
dispatch<any>(initSidePanelTree());
if (router.location) {
const match = matchRootRoute(router.location.pathname);
dispatch(loadMyAccountPanel());
});
+export const loadLinkAccount = handleFirstTimeLoad(
+ (dispatch: Dispatch<any>) => {
+ dispatch(loadLinkAccountPanel());
+ });
+
export const loadKeepServices = handleFirstTimeLoad(
async (dispatch: Dispatch<any>) => {
await dispatch(loadKeepServicesPanel());
import { authActions, getUserDetails, saveApiToken } from "~/store/auth/auth-action";
import { getUrlParameter } from "~/common/url";
import { AuthService } from "~/services/auth-service/auth-service";
-import { navigateToRootProject } from "~/store/navigation/navigation-action";
+import { navigateToRootProject, navigateToLinkAccount } from "~/store/navigation/navigation-action";
import { User } from "~/models/user";
import { Config } from "~/common/config";
import { initSessions } from "~/store/auth/auth-action-session";
+import { getAccountLinkData } from "~/store/link-account-panel/link-account-panel-actions";
interface ApiTokenProps {
authService: AuthService;
this.props.dispatch(initSessions(this.props.authService, this.props.config, user));
}).finally(() => {
if (loadMainApp) {
- this.props.dispatch(navigateToRootProject);
+ if (this.props.dispatch(getAccountLinkData())) {
+ this.props.dispatch(navigateToLinkAccount);
+ }
+ else {
+ this.props.dispatch(navigateToRootProject);
+ }
}
});
}
import {
navigateToSiteManager,
navigateToSshKeysUser,
- navigateToMyAccount
+ navigateToMyAccount,
+ navigateToLinkAccount
} from '~/store/navigation/navigation-action';
import { openUserVirtualMachines } from "~/store/virtual-machines/virtual-machines-actions";
<MenuItem onClick={() => dispatch(navigateToSshKeysUser)}>Ssh Keys</MenuItem>
<MenuItem onClick={() => dispatch(navigateToSiteManager)}>Site Manager</MenuItem>
<MenuItem onClick={() => dispatch(navigateToMyAccount)}>My account</MenuItem>
+ <MenuItem onClick={() => dispatch(navigateToLinkAccount)}>Link account</MenuItem>
</> : null}
<MenuItem>
<a href={`${workbenchURL.replace(/\/$/, "")}/${wb1URL(currentRoute)}?api_token=${apiToken}`}
className={classes.link}>
Switch to Workbench v1</a></MenuItem>
<Divider />
- <MenuItem onClick={() => dispatch(logout())}>Logout</MenuItem>
+ <MenuItem onClick={() => dispatch(logout(true))}>Logout</MenuItem>
</DropdownMenu>
: null));
// SPDX-License-Identifier: AGPL-3.0
import * as React from 'react';
-import { connect, DispatchProp } from 'react-redux';
-import { Grid, Typography, Button, Select } from '@material-ui/core';
+import { Dispatch } from 'redux';
+import { connect } from 'react-redux';
+import { Grid, Typography, Button } from '@material-ui/core';
import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
-import { login, authActions } from '~/store/auth/auth-action';
import { ArvadosTheme } from '~/common/custom-theme';
-import { RootState } from '~/store/store';
-import * as classNames from 'classnames';
+import { navigateToLinkAccount } from '~/store/navigation/navigation-action';
-type CssRules = 'root' | 'container' | 'title' | 'content' | 'content__bolder' | 'button';
+
+type CssRules = 'root' | 'ontop' | 'title';
const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
root: {
opacity: 0.2,
}
},
- container: {
- width: '560px',
+ ontop: {
zIndex: 10
},
title: {
marginBottom: theme.spacing.unit * 6,
color: theme.palette.grey["800"]
- },
- content: {
- marginBottom: theme.spacing.unit * 3,
- lineHeight: '1.2rem',
- color: theme.palette.grey["800"]
- },
- 'content__bolder': {
- fontWeight: 'bolder'
- },
- button: {
- boxShadow: 'none'
}
});
-type LoginPanelProps = DispatchProp<any> & WithStyles<CssRules> & {
- remoteHosts: { [key: string]: string },
- homeCluster: string,
- uuidPrefix: string
-};
+export interface InactivePanelActionProps {
+ startLinking: () => void;
+}
-export const InactivePanel = withStyles(styles)(
- connect((state: RootState) => ({
- remoteHosts: state.auth.remoteHosts,
- homeCluster: state.auth.homeCluster,
- uuidPrefix: state.auth.localCluster
- }))(({ classes, dispatch, remoteHosts, homeCluster, uuidPrefix }: LoginPanelProps) =>
- <Grid container justify="center" alignItems="center"
+const mapDispatchToProps = (dispatch: Dispatch): InactivePanelActionProps => ({
+ startLinking: () => {
+ dispatch<any>(navigateToLinkAccount);
+ }
+});
+
+type InactivePanelProps = WithStyles<CssRules> & InactivePanelActionProps;
+
+export const InactivePanel = connect(null, mapDispatchToProps)(withStyles(styles)((({ classes, startLinking }: InactivePanelProps) =>
+ <Grid container justify="center" alignItems="center" direction="column" spacing={24}
className={classes.root}
- style={{ marginTop: 56, overflowY: "auto", height: "100%" }}>
- <Grid item className={classes.container}>
+ style={{ marginTop: 56, height: "100%" }}>
+ <Grid item>
<Typography variant='h6' align="center" className={classes.title}>
Hi! You're logged in, but...
- </Typography>
- <Typography>
- Your account is inactive.
-
- An administrator must activate your account before you can get any further.
- </Typography>
+ </Typography>
+ </Grid>
+ <Grid item>
+ <Typography align="center">
+ Your account is inactive. An administrator must activate your account before you can get any further.
+ </Typography>
+ </Grid>
+ <Grid item>
+ <Typography align="center">
+ If you would like to use this login to access another account click "Link Account".
+ </Typography>
+ </Grid>
+ <Grid item>
+ <Button className={classes.ontop} color="primary" variant="contained" onClick={() => startLinking()}>
+ Link Account
+ </Button>
</Grid>
</Grid >
- ));
+ )));
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import {
+ StyleRulesCallback,
+ WithStyles,
+ withStyles,
+ Card,
+ CardContent,
+ Button,
+ Grid,
+ Select,
+ CircularProgress
+} from '@material-ui/core';
+import { ArvadosTheme } from '~/common/custom-theme';
+import { UserResource } from "~/models/user";
+import { LinkAccountType } from "~/models/link-account";
+import { formatDate } from "~/common/formatters";
+import { LinkAccountPanelStatus, LinkAccountPanelError } from "~/store/link-account-panel/link-account-panel-reducer";
+
+type CssRules = 'root';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+ root: {
+ width: '100%',
+ overflow: 'auto'
+ }
+});
+
+export interface LinkAccountPanelRootDataProps {
+ targetUser?: UserResource;
+ userToLink?: UserResource;
+ remoteHosts: { [key: string]: string };
+ hasRemoteHosts: boolean;
+ localCluster: string;
+ status : LinkAccountPanelStatus;
+ error: LinkAccountPanelError;
+ selectedCluster?: string;
+ isProcessing: boolean;
+}
+
+export interface LinkAccountPanelRootActionProps {
+ startLinking: (type: LinkAccountType) => void;
+ cancelLinking: () => void;
+ linkAccount: () => void;
+ setSelectedCluster: (cluster: string) => void;
+}
+
+function displayUser(user: UserResource, showCreatedAt: boolean = false, showCluster: boolean = false) {
+ const disp = [];
+ disp.push(<span><b>{user.email}</b> ({user.username}, {user.uuid})</span>);
+ if (showCluster) {
+ const homeCluster = user.uuid.substr(0,5);
+ disp.push(<span> hosted on cluster <b>{homeCluster}</b> and </span>);
+ }
+ if (showCreatedAt) {
+ disp.push(<span> created on <b>{formatDate(user.createdAt)}</b></span>);
+ }
+ return disp;
+}
+
+function isLocalUser(uuid: string, localCluster: string) {
+ return uuid.substring(0, 5) === localCluster;
+}
+
+type LinkAccountPanelRootProps = LinkAccountPanelRootDataProps & LinkAccountPanelRootActionProps & WithStyles<CssRules>;
+
+export const LinkAccountPanelRoot = withStyles(styles) (
+ ({classes, targetUser, userToLink, status, isProcessing, error, startLinking, cancelLinking, linkAccount,
+ remoteHosts, hasRemoteHosts, selectedCluster, setSelectedCluster, localCluster}: LinkAccountPanelRootProps) => {
+ return <Card className={classes.root}>
+ <CardContent>
+ { isProcessing && <Grid container item direction="column" alignContent="center" spacing={24}>
+ <Grid item>
+ Loading user info. Please wait.
+ </Grid>
+ <Grid item style={{ alignSelf: 'center' }}>
+ <CircularProgress/>
+ </Grid>
+ </Grid> }
+ { !isProcessing && status === LinkAccountPanelStatus.INITIAL && targetUser && <div>
+ { isLocalUser(targetUser.uuid, localCluster) ? <Grid container spacing={24}>
+ <Grid container item direction="column" spacing={24}>
+ <Grid item>
+ You are currently logged in as {displayUser(targetUser, true)}
+ </Grid>
+ <Grid item>
+ You can link Arvados accounts. After linking, either login will take you to the same account.
+ </Grid >
+ </Grid>
+ <Grid container item direction="row" spacing={24}>
+ <Grid item>
+ <Button disabled={!targetUser.isActive} color="primary" variant="contained" onClick={() => startLinking(LinkAccountType.ADD_OTHER_LOGIN)}>
+ Add another login to this account
+ </Button>
+ </Grid>
+ <Grid item>
+ <Button color="primary" variant="contained" onClick={() => startLinking(LinkAccountType.ACCESS_OTHER_ACCOUNT)}>
+ Use this login to access another account
+ </Button>
+ </Grid>
+ </Grid>
+ { hasRemoteHosts && selectedCluster && <Grid container item direction="column" spacing={24}>
+ <Grid item>
+ You can also link {displayUser(targetUser, false)} with an account from a remote cluster.
+ </Grid>
+ <Grid item>
+ Please select the cluster that hosts the account you want to link with:
+ <Select id="remoteHostsDropdown" native defaultValue={selectedCluster} style={{ marginLeft: "1em" }}
+ onChange={(event) => setSelectedCluster(event.target.value)}>
+ {Object.keys(remoteHosts).map((k) => k !== localCluster ? <option key={k} value={k}>{k}</option> : null)}
+ </Select>
+ </Grid>
+ <Grid item>
+ <Button color="primary" variant="contained" onClick={() => startLinking(LinkAccountType.ACCESS_OTHER_REMOTE_ACCOUNT)}>
+ Link with an account on {hasRemoteHosts ? <label>{selectedCluster} </label> : null}
+ </Button>
+ </Grid>
+ </Grid> }
+ </Grid> :
+ <Grid container spacing={24}>
+ <Grid container item direction="column" spacing={24}>
+ <Grid item>
+ You are currently logged in as {displayUser(targetUser, true, true)}
+ </Grid>
+ {targetUser.isActive ? <> <Grid item>
+ This a remote account. You can link a local Arvados account to this one. After linking, you can access the local account's data by logging into the <b>{localCluster}</b> cluster as user <b>{targetUser.email}</b> from <b>{targetUser.uuid.substr(0,5)}</b>.
+ </Grid >
+ <Grid item>
+ <Button color="primary" variant="contained" onClick={() => startLinking(LinkAccountType.ADD_LOCAL_TO_REMOTE)}>
+ Link an account from {localCluster} to this account
+ </Button>
+ </Grid> </>
+ : <Grid item>
+ This an inactive remote account. An administrator must activate your account before you can proceed. After your accounts is activated, you can link a local Arvados account hosted by the <b>{localCluster}</b> cluster to this one.
+ </Grid >}
+ </Grid>
+ </Grid>}
+ </div> }
+ { !isProcessing && (status === LinkAccountPanelStatus.LINKING || status === LinkAccountPanelStatus.ERROR) && userToLink && targetUser &&
+ <Grid container spacing={24}>
+ { status === LinkAccountPanelStatus.LINKING && <Grid container item direction="column" spacing={24}>
+ <Grid item>
+ Clicking 'Link accounts' will link {displayUser(userToLink, true, !isLocalUser(targetUser.uuid, localCluster))} to {displayUser(targetUser, true, !isLocalUser(targetUser.uuid, localCluster))}.
+ </Grid>
+ { (isLocalUser(targetUser.uuid, localCluster)) && <Grid item>
+ After linking, logging in as {displayUser(userToLink)} will log you into the same account as {displayUser(targetUser)}.
+ </Grid> }
+ <Grid item>
+ Any object owned by {displayUser(userToLink)} will be transfered to {displayUser(targetUser)}.
+ </Grid>
+ { !isLocalUser(targetUser.uuid, localCluster) && <Grid item>
+ You can access <b>{userToLink.email}</b> data by logging into <b>{localCluster}</b> with the <b>{targetUser.email}</b> account.
+ </Grid> }
+ </Grid> }
+ { error === LinkAccountPanelError.NON_ADMIN && <Grid item>
+ Cannot link admin account {displayUser(userToLink)} to non-admin account {displayUser(targetUser)}.
+ </Grid> }
+ { error === LinkAccountPanelError.SAME_USER && <Grid item>
+ Cannot link {displayUser(targetUser)} to the same account.
+ </Grid> }
+ { error === LinkAccountPanelError.INACTIVE && <Grid item>
+ Cannot link account {displayUser(userToLink)} to inactive account {displayUser(targetUser)}.
+ </Grid> }
+ <Grid container item direction="row" spacing={24}>
+ <Grid item>
+ <Button variant="contained" onClick={() => cancelLinking()}>
+ Cancel
+ </Button>
+ </Grid>
+ <Grid item>
+ <Button disabled={status === LinkAccountPanelStatus.ERROR} color="primary" variant="contained" onClick={() => linkAccount()}>
+ Link accounts
+ </Button>
+ </Grid>
+ </Grid>
+ </Grid> }
+ </CardContent>
+ </Card>;
+});
\ No newline at end of file
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { RootState } from '~/store/store';
+import { Dispatch } from 'redux';
+import { connect } from 'react-redux';
+import { startLinking, cancelLinking, linkAccount, linkAccountPanelActions } from '~/store/link-account-panel/link-account-panel-actions';
+import { LinkAccountType } from '~/models/link-account';
+import {
+ LinkAccountPanelRoot,
+ LinkAccountPanelRootDataProps,
+ LinkAccountPanelRootActionProps
+} from '~/views/link-account-panel/link-account-panel-root';
+
+const mapStateToProps = (state: RootState): LinkAccountPanelRootDataProps => {
+ return {
+ remoteHosts: state.auth.remoteHosts,
+ hasRemoteHosts: Object.keys(state.auth.remoteHosts).length > 1,
+ selectedCluster: state.linkAccountPanel.selectedCluster,
+ localCluster: state.auth.localCluster,
+ targetUser: state.linkAccountPanel.targetUser,
+ userToLink: state.linkAccountPanel.userToLink,
+ status: state.linkAccountPanel.status,
+ error: state.linkAccountPanel.error,
+ isProcessing: state.linkAccountPanel.isProcessing
+ };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch): LinkAccountPanelRootActionProps => ({
+ startLinking: (type: LinkAccountType) => dispatch<any>(startLinking(type)),
+ cancelLinking: () => dispatch<any>(cancelLinking(true)),
+ linkAccount: () => dispatch<any>(linkAccount()),
+ setSelectedCluster: (selectedCluster: string) => dispatch<any>(linkAccountPanelActions.SET_SELECTED_CLUSTER({selectedCluster}))
+});
+
+export const LinkAccountPanel = connect(mapStateToProps, mapDispatchToProps)(LinkAccountPanelRoot);
import { InactivePanel } from '~/views/inactive-panel/inactive-panel';
import { WorkbenchLoadingScreen } from '~/views/workbench/workbench-loading-screen';
import { MainAppBar } from '~/views-components/main-app-bar/main-app-bar';
+import { LinkAccountPanel } from '~/views/link-account-panel/link-account-panel';
type CssRules = 'root';
loading: boolean;
buildInfo: string;
uuidPrefix: string;
+ isNotLinking: boolean;
+ isLinkingPath: boolean;
}
type MainPanelRootProps = MainPanelRootDataProps & WithStyles<CssRules>;
export const MainPanelRoot = withStyles(styles)(
- ({ classes, loading, working, user, buildInfo, uuidPrefix }: MainPanelRootProps) =>
+ ({ classes, loading, working, user, buildInfo, uuidPrefix, isNotLinking, isLinkingPath }: MainPanelRootProps) =>
loading
? <WorkbenchLoadingScreen />
: <>
- <MainAppBar
+ { isNotLinking && <MainAppBar
user={user}
buildInfo={buildInfo}
uuidPrefix={uuidPrefix}>
{working ? <LinearProgress color="secondary" /> : null}
- </MainAppBar>
+ </MainAppBar> }
<Grid container direction="column" className={classes.root}>
- {user ? (user.isActive ? <WorkbenchPanel /> : <InactivePanel />) : <LoginPanel />}
+ { user ? (user.isActive || (!user.isActive && isLinkingPath) ? <WorkbenchPanel isNotLinking={isNotLinking} isUserActive={user.isActive} /> : <InactivePanel />) : <LoginPanel /> }
</Grid>
</>
);
import { MainPanelRoot, MainPanelRootDataProps } from '~/views/main-panel/main-panel-root';
import { isSystemWorking } from '~/store/progress-indicator/progress-indicator-reducer';
import { isWorkbenchLoading } from '~/store/workbench/workbench-actions';
+import { LinkAccountPanelStatus } from '~/store/link-account-panel/link-account-panel-reducer';
+import { matchLinkAccountRoute } from '~/routes/routes';
const mapStateToProps = (state: RootState): MainPanelRootDataProps => {
return {
working: isSystemWorking(state.progressIndicator),
loading: isWorkbenchLoading(state),
buildInfo: state.appInfo.buildInfo,
- uuidPrefix: state.auth.localCluster
+ uuidPrefix: state.auth.localCluster,
+ isNotLinking: state.linkAccountPanel.status === LinkAccountPanelStatus.NONE || state.linkAccountPanel.status === LinkAccountPanelStatus.INITIAL,
+ isLinkingPath: state.router.location ? matchLinkAccountRoute(state.router.location.pathname) !== null : false
};
};
// SPDX-License-Identifier: AGPL-3.0
import * as React from 'react';
+import { connect } from 'react-redux';
import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
import { Route, Switch } from "react-router";
import { ProjectPanel } from "~/views/project-panel/project-panel";
import { AddGroupMembersDialog } from '~/views-components/dialog-forms/add-group-member-dialog';
import { PartialCopyToCollectionDialog } from '~/views-components/dialog-forms/partial-copy-to-collection-dialog';
import { PublicFavoritePanel } from '~/views/public-favorites-panel/public-favorites-panel';
+import { LinkAccountPanel } from '~/views/link-account-panel/link-account-panel';
import { FedLogin } from './fed-login';
import { CollectionsContentAddressPanel } from '~/views/collection-content-address-panel/collection-content-address-panel';
}
});
-type WorkbenchPanelProps = WithStyles<CssRules>;
+interface WorkbenchDataProps {
+ isUserActive: boolean;
+ isNotLinking: boolean;
+}
+
+type WorkbenchPanelProps = WithStyles<CssRules> & WorkbenchDataProps;
const defaultSplitterSize = 90;
const saveSplitterSize = (size: number) => localStorage.setItem('splitterSize', size.toString());
export const WorkbenchPanel =
- withStyles(styles)(({ classes }: WorkbenchPanelProps) =>
- <Grid container item xs className={classes.root}>
- <Grid container item xs className={classes.container}>
- <SplitterLayout customClassName={classes.splitter} percentage={true}
+ withStyles(styles)((props: WorkbenchPanelProps) =>
+ <Grid container item xs className={props.classes.root}>
+ <Grid container item xs className={props.classes.container}>
+ <SplitterLayout customClassName={props.classes.splitter} percentage={true}
primaryIndex={0} primaryMinSize={10}
secondaryInitialSize={getSplitterInitialSize()} secondaryMinSize={40}
onSecondaryPaneSizeChange={saveSplitterSize}>
- <Grid container item xs component='aside' direction='column' className={classes.asidePanel}>
+ { props.isUserActive && props.isNotLinking && <Grid container item xs component='aside' direction='column' className={props.classes.asidePanel}>
<SidePanel />
- </Grid>
- <Grid container item xs component="main" direction="column" className={classes.contentWrapper}>
+ </Grid> }
+ <Grid container item xs component="main" direction="column" className={props.classes.contentWrapper}>
<Grid item xs>
- <MainContentBar />
+ { props.isNotLinking && <MainContentBar /> }
</Grid>
- <Grid item xs className={classes.content}>
+ <Grid item xs className={props.classes.content}>
<Switch>
<Route path={Routes.PROJECTS} component={ProjectPanel} />
<Route path={Routes.COLLECTIONS} component={CollectionPanel} />
<Route path={Routes.GROUP_DETAILS} component={GroupDetailsPanel} />
<Route path={Routes.LINKS} component={LinkPanel} />
<Route path={Routes.PUBLIC_FAVORITES} component={PublicFavoritePanel} />
+ <Route path={Routes.LINK_ACCOUNT} component={LinkAccountPanel} />
<Route path={Routes.COLLECTIONS_CONTENT_ADDRESS} component={CollectionsContentAddressPanel} />
</Switch>
</Grid>