beforeEach(function () {
cy.clearCookies()
cy.clearLocalStorage()
- })
-
- it('checks that Public favorites does not appear under shared with me', function () {
- cy.loginAs(adminUser);
- cy.contains('Shared with me').click();
- cy.get('main').contains('Public favorites').should('not.exist');
});
it('creates and removes a public favorite', function () {
cy.loginAs(adminUser);
+
cy.createGroup(adminUser.token, {
name: `my-favorite-project`,
group_class: 'project',
});
});
- it('can copy collection to favorites', () => {
+ it('can copy selected into the collection', () => {
cy.loginAs(adminUser);
- cy.createGroup(adminUser.token, {
- name: `my-shared-writable-project ${Math.floor(Math.random() * 999999)}`,
- group_class: 'project',
- }).as('mySharedWritableProject').then(function (mySharedWritableProject) {
- cy.contains('Refresh').click();
- cy.get('main').contains(mySharedWritableProject.name).rightclick();
- cy.get('[data-cy=context-menu]').within(() => {
- cy.contains('Share').click();
+ cy.createCollection(adminUser.token, {
+ name: `Test source collection ${Math.floor(Math.random() * 999999)}`,
+ manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+ })
+ .as('testSourceCollection').then(function (testSourceCollection) {
+ cy.shareWith(adminUser.token, activeUser.user.uuid, testSourceCollection.uuid, 'can_read');
});
- cy.get('[id="select-permissions"]').as('selectPermissions');
- cy.get('@selectPermissions').click();
- cy.contains('Write').click();
- cy.get('.sharing-dialog').as('sharingDialog');
- cy.get('[data-cy=invite-people-field]').find('input').type(activeUser.user.email);
- cy.get('[role=tooltip]').click();
- cy.get('@sharingDialog').contains('Save').click();
- });
- cy.createGroup(adminUser.token, {
- name: `my-shared-readonly-project ${Math.floor(Math.random() * 999999)}`,
- group_class: 'project',
- }).as('mySharedReadonlyProject').then(function (mySharedReadonlyProject) {
- cy.contains('Refresh').click();
- cy.get('main').contains(mySharedReadonlyProject.name).rightclick();
- cy.get('[data-cy=context-menu]').within(() => {
- cy.contains('Share').click();
+ cy.createCollection(adminUser.token, {
+ name: `Test target collection ${Math.floor(Math.random() * 999999)}`,
+ })
+ .as('testTargetCollection').then(function (testTargetCollection) {
+ cy.shareWith(adminUser.token, activeUser.user.uuid, testTargetCollection.uuid, 'can_write');
});
- cy.get('.sharing-dialog').as('sharingDialog');
- cy.get('[data-cy=invite-people-field]').find('input').type(activeUser.user.email);
- cy.get('[role=tooltip]').click();
- cy.get('@sharingDialog').contains('Save').click();
- });
- cy.createGroup(activeUser.token, {
- name: `my-project ${Math.floor(Math.random() * 999999)}`,
- group_class: 'project',
- }).as('myProject1');
+ cy.getAll('@testSourceCollection', '@testTargetCollection')
+ .then(function ([testSourceCollection, testTargetCollection]) {
+ cy.loginAs(activeUser);
+
+ cy.get('.layout-pane-primary')
+ .contains('Projects').click();
+
+ cy.addToFavorites(activeUser.token, activeUser.user.uuid, testTargetCollection.uuid);
+
+ cy.get('main').contains(testSourceCollection.name).click();
+ cy.get('[data-cy=collection-files-panel]').contains('bar');
+ cy.get('[data-cy=collection-files-panel]').find('input[type=checkbox]').click({ force: true });
+ cy.get('[data-cy=collection-files-panel-options-btn]').click();
+ cy.get('[data-cy=context-menu]')
+ .contains('Copy selected into the collection').click();
+
+ cy.get('[data-cy=projects-tree-favourites-tree-picker]')
+ .find('i')
+ .click();
+
+ cy.get('[data-cy=projects-tree-favourites-tree-picker]')
+ .contains(testTargetCollection.name)
+ .click();
+
+ cy.get('[data-cy=form-submit-btn]').click();
+
+ cy.get('.layout-pane-primary')
+ .contains('Projects').click();
+
+ cy.get('main').contains(testTargetCollection.name).click();
+
+ cy.get('[data-cy=collection-files-panel]').contains('bar');
+ });
+ });
+
+ it('can copy collection to favorites', () => {
+ cy.createProject({
+ owningUser: adminUser,
+ targetUser: activeUser,
+ projectName: 'mySharedWritableProject',
+ canWrite: true,
+ addToFavorites: true
+ });
+ cy.createProject({
+ owningUser: adminUser,
+ targetUser: activeUser,
+ projectName: 'mySharedReadonlyProject',
+ canWrite: false,
+ addToFavorites: true
+ });
+ cy.createProject({
+ owningUser: activeUser,
+ projectName: 'myProject1',
+ addToFavorites: true
+ });
cy.createCollection(adminUser.token, {
name: `Test collection ${Math.floor(Math.random() * 999999)}`,
.then(function ([mySharedWritableProject, mySharedReadonlyProject, myProject1, testCollection]) {
cy.loginAs(activeUser);
- cy.contains('Shared with me').click();
-
- cy.get('main').contains(mySharedWritableProject.name).rightclick();
- cy.get('[data-cy=context-menu]').within(() => {
- cy.contains('Add to favorites').click();
- });
-
- cy.get('main').contains(mySharedReadonlyProject.name).rightclick();
- cy.get('[data-cy=context-menu]').within(() => {
- cy.contains('Add to favorites').click();
- });
-
cy.doSearch(`${activeUser.user.uuid}`);
- cy.get('main').contains(myProject1.name).rightclick();
- cy.get('[data-cy=context-menu]').within(() => {
- cy.contains('Add to favorites').click();
- });
-
cy.contains(testCollection.name).rightclick();
cy.get('[data-cy=context-menu]').within(() => {
cy.contains('Move to').click();
});
});
- it('can copy selected into the collection', () => {
- cy.loginAs(adminUser);
-
- cy.createCollection(adminUser.token, {
- name: `Test source collection ${Math.floor(Math.random() * 999999)}`,
- manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
- })
- .as('testSourceCollection').then(function (testSourceCollection) {
- cy.contains('Refresh').click();
- cy.get('main').contains(testSourceCollection.name).rightclick();
- cy.get('[data-cy=context-menu]').within(() => {
- cy.contains('Share').click();
- });
- cy.get('[id="select-permissions"]').as('selectPermissions');
- cy.get('@selectPermissions').click();
- cy.contains('Write').click();
- cy.get('.sharing-dialog').as('sharingDialog');
- cy.get('[data-cy=invite-people-field]').find('input').type(activeUser.user.email);
- cy.get('[role=tooltip]').click();
- cy.get('@sharingDialog').contains('Save').click();
- });
-
- cy.createCollection(adminUser.token, {
- name: `Test target collection ${Math.floor(Math.random() * 999999)}`,
- })
- .as('testTargetCollection').then(function (testTargetCollection) {
- cy.contains('Refresh').click();
- cy.get('main').contains(testTargetCollection.name).rightclick();
- cy.get('[data-cy=context-menu]').within(() => {
- cy.contains('Share').click();
- });
- cy.get('[id="select-permissions"]').as('selectPermissions');
- cy.get('@selectPermissions').click();
- cy.contains('Write').click();
- cy.get('.sharing-dialog').as('sharingDialog');
- cy.get('[data-cy=invite-people-field]').find('input').type(activeUser.user.email);
- cy.get('[role=tooltip]').click();
- cy.get('@sharingDialog').contains('Save').click();
- });
+ it('can view favourites in workflow', () => {
+ cy.createProject({
+ owningUser: adminUser,
+ targetUser: activeUser,
+ projectName: 'mySharedWritableProject',
+ canWrite: true,
+ addToFavorites: true
+ });
+ cy.createProject({
+ owningUser: adminUser,
+ targetUser: activeUser,
+ projectName: 'mySharedReadonlyProject',
+ canWrite: false,
+ addToFavorites: true
+ });
+ cy.createProject({
+ owningUser: activeUser,
+ projectName: 'myProject1',
+ addToFavorites: true
+ });
- cy.getAll('@testSourceCollection', '@testTargetCollection')
- .then(function ([testSourceCollection, testTargetCollection]) {
+ cy.getAll('@mySharedWritableProject', '@mySharedReadonlyProject', '@myProject1')
+ .then(function ([mySharedWritableProject, mySharedReadonlyProject, myProject1]) {
cy.loginAs(activeUser);
- cy.get('.layout-pane-primary')
- .contains('Projects').click();
-
- cy.get('main').contains(testTargetCollection.name).rightclick();
- cy.get('[data-cy=context-menu]').within(() => {
- cy.contains('Add to favorites').click();
- });
-
- cy.get('main').contains(testSourceCollection.name).click();
- cy.get('[data-cy=collection-files-panel]').contains('bar');
- cy.get('[data-cy=collection-files-panel]').find('input[type=checkbox]').click({ force: true });
- cy.get('[data-cy=collection-files-panel-options-btn]').click();
- cy.get('[data-cy=context-menu]')
- .contains('Copy selected into the collection').click();
+ cy.createWorkflow(adminUser.token, {
+ name: `TestWorkflow${Math.floor(Math.random() * 999999)}.cwl`,
+ definition: "{\n \"$graph\": [\n {\n \"class\": \"Workflow\",\n \"doc\": \"Reverse the lines in a document, then sort those lines.\",\n \"hints\": [\n {\n \"acrContainerImage\": \"99b0201f4cade456b4c9d343769a3b70+261\",\n \"class\": \"http://arvados.org/cwl#WorkflowRunnerResources\"\n }\n ],\n \"id\": \"#main\",\n \"inputs\": [\n {\n \"default\": null,\n \"doc\": \"The input file to be processed.\",\n \"id\": \"#main/input\",\n \"type\": \"File\"\n },\n {\n \"default\": true,\n \"doc\": \"If true, reverse (decending) sort\",\n \"id\": \"#main/reverse_sort\",\n \"type\": \"boolean\"\n }\n ],\n \"outputs\": [\n {\n \"doc\": \"The output with the lines reversed and sorted.\",\n \"id\": \"#main/output\",\n \"outputSource\": \"#main/sorted/output\",\n \"type\": \"File\"\n }\n ],\n \"steps\": [\n {\n \"id\": \"#main/rev\",\n \"in\": [\n {\n \"id\": \"#main/rev/input\",\n \"source\": \"#main/input\"\n }\n ],\n \"out\": [\n \"#main/rev/output\"\n ],\n \"run\": \"#revtool.cwl\"\n },\n {\n \"id\": \"#main/sorted\",\n \"in\": [\n {\n \"id\": \"#main/sorted/input\",\n \"source\": \"#main/rev/output\"\n },\n {\n \"id\": \"#main/sorted/reverse\",\n \"source\": \"#main/reverse_sort\"\n }\n ],\n \"out\": [\n \"#main/sorted/output\"\n ],\n \"run\": \"#sorttool.cwl\"\n }\n ]\n },\n {\n \"baseCommand\": \"rev\",\n \"class\": \"CommandLineTool\",\n \"doc\": \"Reverse each line using the `rev` command\",\n \"hints\": [\n {\n \"class\": \"ResourceRequirement\",\n \"ramMin\": 8\n }\n ],\n \"id\": \"#revtool.cwl\",\n \"inputs\": [\n {\n \"id\": \"#revtool.cwl/input\",\n \"inputBinding\": {},\n \"type\": \"File\"\n }\n ],\n \"outputs\": [\n {\n \"id\": \"#revtool.cwl/output\",\n \"outputBinding\": {\n \"glob\": \"output.txt\"\n },\n \"type\": \"File\"\n }\n ],\n \"stdout\": \"output.txt\"\n },\n {\n \"baseCommand\": \"sort\",\n \"class\": \"CommandLineTool\",\n \"doc\": \"Sort lines using the `sort` command\",\n \"hints\": [\n {\n \"class\": \"ResourceRequirement\",\n \"ramMin\": 8\n }\n ],\n \"id\": \"#sorttool.cwl\",\n \"inputs\": [\n {\n \"id\": \"#sorttool.cwl/reverse\",\n \"inputBinding\": {\n \"position\": 1,\n \"prefix\": \"-r\"\n },\n \"type\": \"boolean\"\n },\n {\n \"id\": \"#sorttool.cwl/input\",\n \"inputBinding\": {\n \"position\": 2\n },\n \"type\": \"File\"\n }\n ],\n \"outputs\": [\n {\n \"id\": \"#sorttool.cwl/output\",\n \"outputBinding\": {\n \"glob\": \"output.txt\"\n },\n \"type\": \"File\"\n }\n ],\n \"stdout\": \"output.txt\"\n }\n ],\n \"cwlVersion\": \"v1.0\"\n}",
+ owner_uuid: myProject1.uuid,
+ })
+ .as('testWorkflow');
- cy.get('[data-cy=projects-tree-favourites-tree-picker]')
- .find('i')
- .click();
+ cy.contains('Shared with me').click();
- cy.get('[data-cy=projects-tree-favourites-tree-picker]')
- .contains(testTargetCollection.name)
- .click();
+ cy.doSearch(`${activeUser.user.uuid}`);
- cy.get('[data-cy=form-submit-btn]').click();
+ cy.get('main').contains(myProject1.name).click();
- cy.get('.layout-pane-primary')
- .contains('Projects').click();
+ cy.get('[data-cy=side-panel-button]').click();
- cy.get('main').contains(testTargetCollection.name).click();
+ cy.get('#aside-menu-list').contains('Run a process').click();
- cy.get('[data-cy=collection-files-panel]').contains('bar');
+ cy.get('@testWorkflow')
+ .then((testWorkflow) => {
+ cy.get('main').contains(testWorkflow.name).click();
+ cy.get('[data-cy=run-process-next-button]').click();
+ cy.get('[readonly]').click();
+ cy.get('[data-cy=choose-a-file-dialog]').as('chooseFileDialog');
+ cy.get('[data-cy=projects-tree-favourites-tree-picker]').contains('Favorites').closest('ul').find('i').click();
+ cy.get('@chooseFileDialog').find(`[data-id=${mySharedWritableProject.uuid}]`);
+ cy.get('@chooseFileDialog').find(`[data-id=${mySharedReadonlyProject.uuid}]`);
+ });
});
});
});
\ No newline at end of file
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+describe('Sharing tests', function () {
+ let activeUser;
+ let adminUser;
+
+ before(function () {
+ // Only set up common users once. These aren't set up as aliases because
+ // aliases are cleaned up after every test. Also it doesn't make sense
+ // to set the same users on beforeEach() over and over again, so we
+ // separate a little from Cypress' 'Best Practices' here.
+ cy.getUser('admin', 'Admin', 'User', true, true)
+ .as('adminUser').then(function () {
+ adminUser = this.adminUser;
+ }
+ );
+ cy.getUser('collectionuser1', 'Collection', 'User', false, true)
+ .as('activeUser').then(function () {
+ activeUser = this.activeUser;
+ }
+ );
+ })
+
+ beforeEach(function () {
+ cy.clearCookies()
+ cy.clearLocalStorage()
+ });
+
+ it('can share projects to other users', () => {
+ cy.loginAs(adminUser);
+
+ cy.createGroup(adminUser.token, {
+ name: `my-shared-writable-project ${Math.floor(Math.random() * 999999)}`,
+ group_class: 'project',
+ }).as('mySharedWritableProject').then(function (mySharedWritableProject) {
+ cy.contains('Refresh').click();
+ cy.get('main').contains(mySharedWritableProject.name).rightclick();
+ cy.get('[data-cy=context-menu]').within(() => {
+ cy.contains('Share').click();
+ });
+ cy.get('[id="select-permissions"]').as('selectPermissions');
+ cy.get('@selectPermissions').click();
+ cy.contains('Write').click();
+ cy.get('.sharing-dialog').as('sharingDialog');
+ cy.get('[data-cy=invite-people-field]').find('input').type(activeUser.user.email);
+ cy.get('[role=tooltip]').click();
+ cy.get('@sharingDialog').contains('Save').click();
+ });
+
+ cy.createGroup(adminUser.token, {
+ name: `my-shared-readonly-project ${Math.floor(Math.random() * 999999)}`,
+ group_class: 'project',
+ }).as('mySharedReadonlyProject').then(function (mySharedReadonlyProject) {
+ cy.contains('Refresh').click();
+ cy.get('main').contains(mySharedReadonlyProject.name).rightclick();
+ cy.get('[data-cy=context-menu]').within(() => {
+ cy.contains('Share').click();
+ });
+ cy.get('.sharing-dialog').as('sharingDialog');
+ cy.get('[data-cy=invite-people-field]').find('input').type(activeUser.user.email);
+ cy.get('[role=tooltip]').click();
+ cy.get('@sharingDialog').contains('Save').click();
+ });
+
+ cy.getAll('@mySharedWritableProject', '@mySharedReadonlyProject')
+ .then(function ([mySharedWritableProject, mySharedReadonlyProject]) {
+ cy.loginAs(activeUser);
+
+ cy.contains('Shared with me').click();
+
+ cy.get('main').contains(mySharedWritableProject.name).rightclick();
+ cy.get('[data-cy=context-menu]').should('contain', 'Move to trash');
+ cy.get('[data-cy=context-menu]').contains('Move to trash').click();
+
+ cy.get('main').contains(mySharedReadonlyProject.name).rightclick();
+ cy.get('[data-cy=context-menu]').should('not.contain', 'Move to trash');
+ });
+ });
+});
\ No newline at end of file
}
return promise
-})
\ No newline at end of file
+})
+
+Cypress.Commands.add('shareWith', (srcUserToken, targetUserUUID, itemUUID, permission = 'can_write') => {
+ cy.createLink(srcUserToken, {
+ name: permission,
+ link_class: 'permission',
+ head_uuid: itemUUID,
+ tail_uuid: targetUserUUID
+ });
+})
+
+Cypress.Commands.add('addToFavorites', (activeUserToken, activeUserUUID, itemUUID) => {
+ cy.createLink(activeUserToken, {
+ head_uuid: itemUUID,
+ link_class: 'star',
+ name: '',
+ owner_uuid: activeUserUUID,
+ tail_uuid: activeUserUUID,
+ });
+})
+
+Cypress.Commands.add('createProject', ({
+ owningUser,
+ targetUser,
+ projectName,
+ canWrite,
+ addToFavorites
+}) => {
+ const writePermission = canWrite ? 'can_write' : 'can_read';
+
+ cy.createGroup(owningUser.token, {
+ name: `${projectName} ${Math.floor(Math.random() * 999999)}`,
+ group_class: 'project',
+ }).as(`${projectName}`).then((project) => {
+ if (targetUser && targetUser !== owningUser) {
+ cy.shareWith(owningUser.token, targetUser.user.uuid, project.uuid, writePermission);
+ }
+ if (addToFavorites) {
+ const user = targetUser ? targetUser : owningUser;
+ cy.addToFavorites(user.token, user.user.uuid, project.uuid);
+ }
+ });
+});
\ No newline at end of file
className={classNames(classes.root, className)}>
{
lines.map((line: string, index: number) => {
- return <Typography key={index} className={apiResponse ? classes.space : ''} component="pre">{line}</Typography>;
+ return <Typography key={index} className={apiResponse ? classes.space : className} component="pre">{line}</Typography>;
})
}
</Typography>
let valueNode: React.ReactNode;
if (linkToUuid) {
- const uuid = uuidEnhancer ? uuidEnhancer(linkToUuid) : linkToUuid;
+ const uuid = uuidEnhancer ? uuidEnhancer(linkToUuid) : linkToUuid;
const linkUrl = getNavUrl(linkToUuid || "", { localCluster, remoteHostsConfig, sessions });
if (linkUrl[0] === '/') {
valueNode = <Link to={linkUrl} className={classes.link}>{uuid}</Link>;
import { initWebSocket } from '~/websocket/websocket';
import { Config } from '~/common/config';
import { addRouteChangeHandlers } from './routes/route-change-handlers';
-import { setCurrentTokenDialogApiHost } from '~/store/current-token-dialog/current-token-dialog-actions';
+import { setTokenDialogApiHost } from '~/store/token-dialog/token-dialog-actions';
import { processResourceActionSet } from '~/views-components/context-menu/action-sets/process-resource-action-set';
import { progressIndicatorActions } from '~/store/progress-indicator/progress-indicator-actions';
import { trashedCollectionActionSet } from '~/views-components/context-menu/action-sets/trashed-collection-action-set';
store.subscribe(initListener(history, store, services, config));
store.dispatch(initAuth(config));
store.dispatch(setBuildInfo());
- store.dispatch(setCurrentTokenDialogApiHost(apiHost));
+ store.dispatch(setTokenDialogApiHost(apiHost));
store.dispatch(loadVocabulary);
store.dispatch(loadFileViewersConfig);
ownerUuid: string;
defaultOwnerUuid: string;
scopes: string[];
-}
\ No newline at end of file
+}
+
+export const getTokenV2 = (aca: ApiClientAuthorization): string =>
+ `v2/${aca.uuid}/${aca.apiToken}`;
\ No newline at end of file
});
}
- create(data?: Partial<T>) {
+ create(data?: Partial<T>, showErrors?: boolean) {
return CommonService.defaultResponse(
this.serverApi
.post<T>(this.resourceType, data && CommonService.mapKeys(_.snakeCase)(data)),
- this.actions
+ this.actions,
+ true, // mapKeys
+ showErrors
);
}
//
// SPDX-License-Identifier: AGPL-3.0
-import { initAuth } from "./auth-action";
+import { getNewExtraToken, initAuth } from "./auth-action";
import { API_TOKEN_KEY } from "~/services/auth-service/auth-service";
import 'jest-localstorage-mock';
import { ServiceRepository, createServices } from "~/services/services";
import { configureStore, RootStore } from "../store";
import { createBrowserHistory } from "history";
-import { mockConfig, DISCOVERY_DOC_PATH, } from '~/common/config';
+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';
import Axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { ImportMock } from 'ts-mock-imports';
import * as servicesModule from "~/services/services";
+import { SessionStatus } from "~/models/session";
describe('auth-actions', () => {
const axiosInst = Axios.create({ headers: {} });
importMocks.map(m => m.restore());
});
- it('should initialise state with user and api token from local storage', (done) => {
-
+ it('creates an extra token', async () => {
axiosMock
.onGet("/users/current")
.reply(200, {
is_active: true,
username: "jdoe",
prefs: {}
+ })
+ .onGet("/api_client_authorizations/current")
+ .reply(200, {
+ expires_at: "2140-01-01T00:00:00.000Z",
+ api_token: 'extra token',
+ })
+ .onPost("/api_client_authorizations")
+ .replyOnce(200, {
+ uuid: 'zzzzz-gj3su-xxxxxxxxxx',
+ apiToken: 'extra token',
+ })
+ .onPost("/api_client_authorizations")
+ .reply(200, {
+ uuid: 'zzzzz-gj3su-xxxxxxxxxx',
+ apiToken: 'extra additional token',
});
+ importMocks.push(ImportMock.mockFunction(servicesModule, 'createServices', services));
+ sessionStorage.setItem(ACCOUNT_LINK_STATUS_KEY, "0");
+ localStorage.setItem(API_TOKEN_KEY, "token");
+
+ const config: any = {
+ rootUrl: "https://zzzzz.arvadosapi.com",
+ uuidPrefix: "zzzzz",
+ remoteHosts: { },
+ apiRevision: 12345678,
+ clusterConfig: {
+ Login: { LoginCluster: "" },
+ },
+ };
+
+ // Set up auth, confirm that no extra token was requested
+ await store.dispatch(initAuth(config))
+ expect(store.getState().auth.apiToken).toBeDefined();
+ expect(store.getState().auth.extraApiToken).toBeUndefined();
+
+ // Ask for an extra token
+ await store.dispatch(getNewExtraToken());
+ expect(store.getState().auth.apiToken).toBeDefined();
+ expect(store.getState().auth.extraApiToken).toBeDefined();
+ const extraToken = store.getState().auth.extraApiToken;
+
+ // Ask for a cached extra token
+ await store.dispatch(getNewExtraToken(true));
+ expect(store.getState().auth.extraApiToken).toBe(extraToken);
+
+ // Ask for a new extra token, should make a second api request
+ await store.dispatch(getNewExtraToken(false));
+ expect(store.getState().auth.extraApiToken).toBeDefined();
+ expect(store.getState().auth.extraApiToken).not.toBe(extraToken);
+ });
+
+ it('should initialise state with user and api token from local storage', (done) => {
axiosMock
+ .onGet("/users/current")
+ .reply(200, {
+ email: "test@test.com",
+ first_name: "John",
+ last_name: "Doe",
+ uuid: "zzzzz-tpzed-abcefg",
+ owner_uuid: "ownerUuid",
+ is_admin: false,
+ is_active: true,
+ username: "jdoe",
+ prefs: {}
+ })
+ .onGet("/api_client_authorizations/current")
+ .reply(200, {
+ expires_at: "2140-01-01T00:00:00.000Z"
+ })
.onGet("https://xc59z.arvadosapi.com/discovery/v1/apis/arvados/v1/rest")
.reply(200, {
baseUrl: "https://xc59z.arvadosapi.com/arvados/v1",
uuidPrefix: "zzzzz",
remoteHosts: { xc59z: "xc59z.arvadosapi.com" },
apiRevision: 12345678,
+ clusterConfig: {
+ Login: { LoginCluster: "" },
+ },
};
store.dispatch(initAuth(config));
const auth = store.getState().auth;
if (auth.apiToken === "token" &&
auth.sessions.length === 2 &&
- auth.sessions[0].status === 2 &&
- auth.sessions[1].status === 2
+ auth.sessions[0].status === SessionStatus.VALIDATED &&
+ auth.sessions[1].status === SessionStatus.VALIDATED
) {
try {
expect(auth).toEqual({
apiToken: "token",
+ apiTokenExpiration: new Date("2140-01-01T00:00:00.000Z"),
config: {
apiRevision: 12345678,
+ clusterConfig: {
+ Login: {
+ LoginCluster: "",
+ },
+ },
remoteHosts: {
"xc59z": "xc59z.arvadosapi.com",
},
uuidPrefix: "zzzzz",
},
sshKeys: [],
+ extraApiToken: undefined,
+ extraApiTokenExpiration: undefined,
homeCluster: "zzzzz",
localCluster: "zzzzz",
loginCluster: undefined,
remoteHostsConfig: {
"zzzzz": {
"apiRevision": 12345678,
+ "clusterConfig": {
+ "Login": {
+ "LoginCluster": "",
+ },
+ },
"remoteHosts": {
"xc59z": "xc59z.arvadosapi.com",
},
});
done();
} catch (e) {
- console.log(e);
+ fail(e);
}
}
});
import { progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions";
import { WORKBENCH_LOADING_SCREEN } from '~/store/workbench/workbench-actions';
import { addRemoteConfig } from './auth-action-session';
+import { getTokenV2 } from '~/models/api-client-authorization';
export const authActions = unionize({
LOGIN: {},
LOGOUT: ofType<{ deleteLinkData: boolean }>(),
SET_CONFIG: ofType<{ config: Config }>(),
- INIT_USER: ofType<{ user: User, token: string }>(),
+ SET_EXTRA_TOKEN: ofType<{ extraApiToken: string, extraApiTokenExpiration?: Date }>(),
+ RESET_EXTRA_TOKEN: {},
+ INIT_USER: ofType<{ user: User, token: string, tokenExpiration?: Date }>(),
USER_DETAILS_REQUEST: {},
USER_DETAILS_SUCCESS: ofType<User>(),
SET_SSH_KEYS: ofType<SshKeyResource[]>(),
REMOTE_CLUSTER_CONFIG: ofType<{ config: Config }>(),
});
-export const initAuth = (config: Config) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+export const initAuth = (config: Config) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
// 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));
+ await dispatch<any>(cancelLinking());
}
+ return dispatch<any>(init(config));
};
-const init = (config: Config) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+const init = (config: Config) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
const remoteHosts = () => getState().auth.remoteHosts;
const token = services.authService.getApiToken();
let homeCluster = services.authService.getHomeCluster();
if (token && token !== "undefined") {
dispatch(progressIndicatorActions.START_WORKING(WORKBENCH_LOADING_SCREEN));
- dispatch<any>(saveApiToken(token)).then(() => {
+ try {
+ await dispatch<any>(saveApiToken(token)); // .then(() => {
+ await dispatch(progressIndicatorActions.STOP_WORKING(WORKBENCH_LOADING_SCREEN));
+ } catch (e) {
dispatch(progressIndicatorActions.STOP_WORKING(WORKBENCH_LOADING_SCREEN));
- }).catch(() => {
- dispatch(progressIndicatorActions.STOP_WORKING(WORKBENCH_LOADING_SCREEN));
- });
+ }
}
};
return state.remoteHostsConfig[state.localCluster];
};
-export const saveApiToken = (token: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
+export const saveApiToken = (token: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
const config = dispatch<any>(getConfig);
const svc = createServices(config, { progressFn: () => { }, errorFn: () => { } });
setAuthorizationHeader(svc, token);
- return svc.authService.getUserDetails().then((user: User) => {
- dispatch(authActions.INIT_USER({ user, token }));
- }).catch(() => {
+ try {
+ const user = await svc.authService.getUserDetails();
+ const client = await svc.apiClientAuthorizationService.get('current');
+ const tokenExpiration = client.expiresAt ? new Date(client.expiresAt) : undefined;
+ dispatch(authActions.INIT_USER({ user, token, tokenExpiration }));
+ } catch (e) {
dispatch(authActions.LOGOUT({ deleteLinkData: false }));
- });
+ }
};
+export const getNewExtraToken = (reuseStored: boolean = false) =>
+ async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+ const extraToken = getState().auth.extraApiToken;
+ if (reuseStored && extraToken !== undefined) {
+ const config = dispatch<any>(getConfig);
+ const svc = createServices(config, { progressFn: () => { }, errorFn: () => { } });
+ setAuthorizationHeader(svc, extraToken);
+ try {
+ // Check the extra token's validity before using it. Refresh its
+ // expiration date just in case it changed.
+ const client = await svc.apiClientAuthorizationService.get('current');
+ dispatch(authActions.SET_EXTRA_TOKEN({
+ extraApiToken: extraToken,
+ extraApiTokenExpiration: client.expiresAt ? new Date(client.expiresAt): undefined,
+ }));
+ return extraToken;
+ } catch (e) {
+ dispatch(authActions.RESET_EXTRA_TOKEN());
+ }
+ }
+ const user = getState().auth.user;
+ const loginCluster = getState().auth.config.clusterConfig.Login.LoginCluster;
+ if (user === undefined) { return; }
+ if (loginCluster !== "" && getState().auth.homeCluster !== loginCluster) { return; }
+ try {
+ // Do not show errors on the create call, cluster security configuration may not
+ // allow token creation and there's no way to know that from workbench2 side in advance.
+ const client = await services.apiClientAuthorizationService.create(undefined, false);
+ const newExtraToken = getTokenV2(client);
+ dispatch(authActions.SET_EXTRA_TOKEN({
+ extraApiToken: newExtraToken,
+ extraApiTokenExpiration: client.expiresAt ? new Date(client.expiresAt): undefined,
+ }));
+ return newExtraToken;
+ } catch {
+ console.warn("Cannot create new tokens with the current token, probably because of cluster's security settings.");
+ return;
+ }
+ };
+
export const login = (uuidPrefix: string, homeCluster: string, loginCluster: string,
remoteHosts: { [key: string]: string }) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
services.authService.login(uuidPrefix, homeCluster, loginCluster, remoteHosts);
export interface AuthState {
user?: User;
apiToken?: string;
+ apiTokenExpiration?: Date;
+ extraApiToken?: string;
+ extraApiTokenExpiration?: Date;
sshKeys: SshKeyResource[];
sessions: Session[];
localCluster: string;
const initialState: AuthState = {
user: undefined,
apiToken: undefined,
+ apiTokenExpiration: undefined,
+ extraApiToken: undefined,
+ extraApiTokenExpiration: undefined,
sshKeys: [],
sessions: [],
localCluster: "",
export const authReducer = (services: ServiceRepository) => (state = initialState, action: AuthAction) => {
return authActions.match(action, {
- SET_CONFIG: ({ config }) => {
- return {
+ SET_CONFIG: ({ config }) =>
+ ({
...state,
config,
localCluster: config.uuidPrefix,
- remoteHosts: { ...config.remoteHosts, [config.uuidPrefix]: new URL(config.rootUrl).host },
+ remoteHosts: {
+ ...config.remoteHosts,
+ [config.uuidPrefix]: new URL(config.rootUrl).host
+ },
homeCluster: config.loginCluster || config.uuidPrefix,
loginCluster: config.loginCluster,
- remoteHostsConfig: { ...state.remoteHostsConfig, [config.uuidPrefix]: config }
- };
- },
- REMOTE_CLUSTER_CONFIG: ({ config }) => {
- return {
+ remoteHostsConfig: {
+ ...state.remoteHostsConfig,
+ [config.uuidPrefix]: config
+ }
+ }),
+ REMOTE_CLUSTER_CONFIG: ({ config }) =>
+ ({
...state,
- remoteHostsConfig: { ...state.remoteHostsConfig, [config.uuidPrefix]: config },
- };
- },
- INIT_USER: ({ user, token }) => {
- return { ...state, user, apiToken: token, homeCluster: user.uuid.substr(0, 5) };
- },
- LOGIN: () => {
- return state;
- },
- LOGOUT: () => {
- return { ...state, apiToken: undefined };
- },
- USER_DETAILS_SUCCESS: (user: User) => {
- return { ...state, user, homeCluster: user.uuid.substr(0, 5) };
- },
- SET_SSH_KEYS: (sshKeys: SshKeyResource[]) => {
- return { ...state, sshKeys };
- },
- ADD_SSH_KEY: (sshKey: SshKeyResource) => {
- return { ...state, sshKeys: state.sshKeys.concat(sshKey) };
- },
- REMOVE_SSH_KEY: (uuid: string) => {
- return { ...state, sshKeys: state.sshKeys.filter((sshKey) => sshKey.uuid !== uuid) };
- },
- SET_HOME_CLUSTER: (homeCluster: string) => {
- return { ...state, homeCluster };
- },
- SET_SESSIONS: (sessions: Session[]) => {
- return { ...state, sessions };
- },
- ADD_SESSION: (session: Session) => {
- return { ...state, sessions: state.sessions.concat(session) };
- },
- REMOVE_SESSION: (clusterId: string) => {
- return {
+ remoteHostsConfig: {
+ ...state.remoteHostsConfig,
+ [config.uuidPrefix]: config
+ },
+ }),
+ SET_EXTRA_TOKEN: ({ extraApiToken, extraApiTokenExpiration }) =>
+ ({ ...state, extraApiToken, extraApiTokenExpiration }),
+ RESET_EXTRA_TOKEN: () =>
+ ({ ...state, extraApiToken: undefined, extraApiTokenExpiration: undefined }),
+ INIT_USER: ({ user, token, tokenExpiration }) =>
+ ({ ...state,
+ user,
+ apiToken: token,
+ apiTokenExpiration: tokenExpiration,
+ homeCluster: user.uuid.substr(0, 5)
+ }),
+ LOGIN: () => state,
+ LOGOUT: () => ({ ...state, apiToken: undefined }),
+ USER_DETAILS_SUCCESS: (user: User) =>
+ ({ ...state, user, homeCluster: user.uuid.substr(0, 5) }),
+ SET_SSH_KEYS: (sshKeys: SshKeyResource[]) => ({ ...state, sshKeys }),
+ ADD_SSH_KEY: (sshKey: SshKeyResource) =>
+ ({ ...state, sshKeys: state.sshKeys.concat(sshKey) }),
+ REMOVE_SSH_KEY: (uuid: string) =>
+ ({ ...state, sshKeys: state.sshKeys.filter((sshKey) => sshKey.uuid !== uuid) }),
+ SET_HOME_CLUSTER: (homeCluster: string) => ({ ...state, homeCluster }),
+ SET_SESSIONS: (sessions: Session[]) => ({ ...state, sessions }),
+ ADD_SESSION: (session: Session) =>
+ ({ ...state, sessions: state.sessions.concat(session) }),
+ REMOVE_SESSION: (clusterId: string) =>
+ ({
...state,
sessions: state.sessions.filter(
session => session.clusterId !== clusterId
)
- };
- },
- UPDATE_SESSION: (session: Session) => {
- return {
+ }),
+ UPDATE_SESSION: (session: Session) =>
+ ({
...state,
sessions: state.sessions.map(
s => s.clusterId === session.clusterId ? session : s
)
- };
- },
+ }),
default: () => state
});
};
import { RootState } from "~/store/store";
import { ServiceRepository } from "~/services/services";
import { dialogActions } from '~/store/dialog/dialog-actions';
+import { getNewExtraToken } from "../auth/auth-action";
export const COLLECTION_WEBDAV_S3_DIALOG_NAME = 'collectionWebdavS3Dialog';
}
export const openWebDavS3InfoDialog = (uuid: string, activeTab?: number) =>
- (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+ async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+ await dispatch<any>(getNewExtraToken(true));
dispatch(dialogActions.OPEN_DIALOG({
id: COLLECTION_WEBDAV_S3_DIALOG_NAME,
data: {
title: 'Access Collection using WebDAV or S3',
- token: getState().auth.apiToken,
+ token: getState().auth.extraApiToken || getState().auth.apiToken,
downloadUrl: getState().auth.config.keepWebServiceUrl,
collectionsUrl: getState().auth.config.keepWebInlineServiceUrl,
localCluster: getState().auth.localCluster,
+++ /dev/null
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { dialogActions } from "~/store/dialog/dialog-actions";
-import { getProperty } from '~/store/properties/properties';
-import { propertiesActions } from '~/store/properties/properties-actions';
-import { RootState } from '~/store/store';
-
-export const CURRENT_TOKEN_DIALOG_NAME = 'currentTokenDialog';
-const API_HOST_PROPERTY_NAME = 'apiHost';
-
-export interface CurrentTokenDialogData {
- currentToken: string;
- apiHost: string;
-}
-
-export const setCurrentTokenDialogApiHost = (apiHost: string) =>
- propertiesActions.SET_PROPERTY({ key: API_HOST_PROPERTY_NAME, value: apiHost });
-
-export const getCurrentTokenDialogData = (state: RootState): CurrentTokenDialogData => ({
- apiHost: getProperty<string>(API_HOST_PROPERTY_NAME)(state.properties) || '',
- currentToken: state.auth.apiToken || '',
-});
-
-export const openCurrentTokenDialog = dialogActions.OPEN_DIALOG({ id: CURRENT_TOKEN_DIALOG_NAME, data: {} });
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { dialogActions } from "~/store/dialog/dialog-actions";
+import { getProperty } from '~/store/properties/properties';
+import { propertiesActions } from '~/store/properties/properties-actions';
+import { RootState } from '~/store/store';
+
+export const TOKEN_DIALOG_NAME = 'tokenDialog';
+const API_HOST_PROPERTY_NAME = 'apiHost';
+
+export interface TokenDialogData {
+ token: string;
+ tokenExpiration?: Date;
+ apiHost: string;
+ canCreateNewTokens: boolean;
+}
+
+export const setTokenDialogApiHost = (apiHost: string) =>
+ propertiesActions.SET_PROPERTY({ key: API_HOST_PROPERTY_NAME, value: apiHost });
+
+export const getTokenDialogData = (state: RootState): TokenDialogData => {
+ const loginCluster = state.auth.config.clusterConfig.Login.LoginCluster;
+ const canCreateNewTokens = !(loginCluster !== "" && state.auth.homeCluster !== loginCluster);
+
+ return {
+ apiHost: getProperty<string>(API_HOST_PROPERTY_NAME)(state.properties) || '',
+ token: state.auth.extraApiToken || state.auth.apiToken || '',
+ tokenExpiration: state.auth.extraApiToken
+ ? state.auth.extraApiTokenExpiration
+ : state.auth.apiTokenExpiration,
+ canCreateNewTokens,
+ };
+};
+
+export const openTokenDialog = dialogActions.OPEN_DIALOG({ id: TOKEN_DIALOG_NAME, data: {} });
import { getResource } from '~/store/resources/resources';
import { navigateTo, navigateToUsers, navigateToRootProject } from "~/store/navigation/navigation-action";
import { authActions } from '~/store/auth/auth-action';
+import { getTokenV2 } from "~/models/api-client-authorization";
export const USERS_PANEL_ID = 'usersPanel';
export const USER_ATTRIBUTES_DIALOG = 'userAttributesDialog';
const data = getResource<UserResource>(uuid)(resources);
const client = await services.apiClientAuthorizationService.create({ ownerUuid: uuid });
if (data) {
- dispatch<any>(authActions.INIT_USER({ user: data, token: `v2/${client.uuid}/${client.apiToken}` }));
+ dispatch<any>(authActions.INIT_USER({ user: data, token: getTokenV2(client) }));
location.reload();
dispatch<any>(navigateToRootProject);
}
import * as React from 'react';
import { configure, mount } from "enzyme";
import * as Adapter from 'enzyme-adapter-react-16';
-import { AutoLogoutComponent, AutoLogoutProps } from './auto-logout';
+import { AutoLogoutComponent, AutoLogoutProps, LAST_ACTIVE_TIMESTAMP } from './auto-logout';
configure({ adapter: new Adapter() });
let props: AutoLogoutProps;
const sessionIdleTimeout = 300;
const lastWarningDuration = 60;
+ const eventListeners = {};
jest.useFakeTimers();
+ beforeAll(() => {
+ window.addEventListener = jest.fn((event, cb) => {
+ eventListeners[event] = cb;
+ });
+ });
+
beforeEach(() => {
props = {
sessionIdleTimeout: sessionIdleTimeout,
jest.runTimersToTime(1*1000);
expect(props.doWarn).toBeCalled();
});
+
+ it('should reset the idle timer when activity event is received', () => {
+ jest.runTimersToTime((sessionIdleTimeout-lastWarningDuration-1)*1000);
+ expect(props.doWarn).not.toBeCalled();
+ // Simulate activity from other window/tab
+ eventListeners.storage({
+ key: LAST_ACTIVE_TIMESTAMP,
+ newValue: '42' // value currently doesn't matter
+ })
+ jest.runTimersToTime(1*1000);
+ // Warning should not appear because idle timer was reset
+ expect(props.doWarn).not.toBeCalled();
+ });
});
\ No newline at end of file
doCloseWarn: () => void;
}
-const mapStateToProps = (state: RootState, ownProps: any): AutoLogoutDataProps => {
- return {
- sessionIdleTimeout: parse(state.auth.config.clusterConfig.Workbench.IdleTimeout, 's') || 0,
- lastWarningDuration: ownProps.lastWarningDuration || 60,
- };
-};
+const mapStateToProps = (state: RootState, ownProps: any): AutoLogoutDataProps => ({
+ sessionIdleTimeout: parse(state.auth.config.clusterConfig.Workbench.IdleTimeout, 's') || 0,
+ lastWarningDuration: ownProps.lastWarningDuration || 60,
+});
const mapDispatchToProps = (dispatch: Dispatch): AutoLogoutActionProps => ({
doLogout: () => dispatch<any>(logout(true)),
export type AutoLogoutProps = AutoLogoutDataProps & AutoLogoutActionProps;
+const debounce = (delay: number | undefined, fn: Function) => {
+ let timerId: number | null;
+ return (...args: any[]) => {
+ if (timerId) { clearTimeout(timerId); }
+ timerId = setTimeout(() => {
+ fn(...args);
+ timerId = null;
+ }, delay);
+ };
+};
+
+export const LAST_ACTIVE_TIMESTAMP = 'lastActiveTimestamp';
+
export const AutoLogoutComponent = (props: AutoLogoutProps) => {
let logoutTimer: NodeJS.Timer;
- const lastWarningDuration = min([props.lastWarningDuration, props.sessionIdleTimeout])! * 1000 ;
+ const lastWarningDuration = min([props.lastWarningDuration, props.sessionIdleTimeout])! * 1000;
+
+ // Runs once after render
+ React.useEffect(() => {
+ window.addEventListener('storage', handleStorageEvents);
+ // Component cleanup
+ return () => {
+ window.removeEventListener('storage', handleStorageEvents);
+ };
+ }, []);
+
+ const handleStorageEvents = (e: StorageEvent) => {
+ if (e.key === LAST_ACTIVE_TIMESTAMP) {
+ // Other tab activity detected by a localStorage change event.
+ debounce(500, () => {
+ handleOnActive();
+ reset();
+ })();
+ }
+ };
const handleOnIdle = () => {
logoutTimer = setTimeout(
};
const handleOnActive = () => {
- clearTimeout(logoutTimer);
+ if (logoutTimer) { clearTimeout(logoutTimer); }
props.doCloseWarn();
};
- useIdleTimer({
+ const handleOnAction = () => {
+ // Notify the other tabs there was some activity.
+ const now = (new Date).getTime();
+ localStorage.setItem(LAST_ACTIVE_TIMESTAMP, now.toString());
+ };
+
+ const { reset } = useIdleTimer({
timeout: (props.lastWarningDuration < props.sessionIdleTimeout)
? 1000 * (props.sessionIdleTimeout - props.lastWarningDuration)
: 1,
onIdle: handleOnIdle,
onActive: handleOnActive,
+ onAction: handleOnAction,
debounce: 500
});
+++ /dev/null
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import * as React from 'react';
-import { Button } from '@material-ui/core';
-import { mount, configure } from 'enzyme';
-import * as Adapter from 'enzyme-adapter-react-16';
-import * as CopyToClipboard from 'react-copy-to-clipboard';
-import { CurrentTokenDialogComponent } from './current-token-dialog';
-
-configure({ adapter: new Adapter() });
-
-jest.mock('toggle-selection', () => () => () => null);
-
-describe('<CurrentTokenDialog />', () => {
- let props;
- let wrapper;
-
- beforeEach(() => {
- props = {
- classes: {},
- data: {
- currentToken: '123123123123',
- },
- open: true,
- dispatch: jest.fn(),
- };
- });
-
- describe('copy to clipboard', () => {
- beforeEach(() => {
- wrapper = mount(<CurrentTokenDialogComponent {...props} />);
- });
-
- it('should copy API TOKEN to the clipboard', () => {
- // when
- wrapper.find(CopyToClipboard).find(Button).simulate('click');
-
- // and
- expect(props.dispatch).toHaveBeenCalledWith({
- payload: {
- hideDuration: 2000,
- kind: 1,
- message: 'Token copied to clipboard',
- },
- type: 'OPEN_SNACKBAR',
- });
- });
- });
-});
+++ /dev/null
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import * as React from 'react';
-import { Dialog, DialogActions, DialogTitle, DialogContent, WithStyles, withStyles, StyleRulesCallback, Button, Typography } from '@material-ui/core';
-import * as CopyToClipboard from 'react-copy-to-clipboard';
-import { ArvadosTheme } from '~/common/custom-theme';
-import { withDialog } from '~/store/dialog/with-dialog';
-import { WithDialogProps } from '~/store/dialog/with-dialog';
-import { connect, DispatchProp } from 'react-redux';
-import { CurrentTokenDialogData, getCurrentTokenDialogData, CURRENT_TOKEN_DIALOG_NAME } from '~/store/current-token-dialog/current-token-dialog-actions';
-import { DefaultCodeSnippet } from '~/components/default-code-snippet/default-code-snippet';
-import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
-
-type CssRules = 'link' | 'paper' | 'button' | 'copyButton';
-
-const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
- link: {
- color: theme.palette.primary.main,
- textDecoration: 'none',
- margin: '0px 4px'
- },
- paper: {
- padding: theme.spacing.unit,
- marginBottom: theme.spacing.unit * 2,
- backgroundColor: theme.palette.grey["200"],
- border: `1px solid ${theme.palette.grey["300"]}`
- },
- button: {
- fontSize: '0.8125rem',
- fontWeight: 600
- },
- copyButton: {
- boxShadow: 'none',
- marginTop: theme.spacing.unit * 2,
- marginBottom: theme.spacing.unit * 2,
- }
-});
-
-type CurrentTokenProps = CurrentTokenDialogData & WithDialogProps<{}> & WithStyles<CssRules> & DispatchProp;
-
-export class CurrentTokenDialogComponent extends React.Component<CurrentTokenProps> {
- onCopy = (message: string) => {
- this.props.dispatch(snackbarActions.OPEN_SNACKBAR({
- message,
- hideDuration: 2000,
- kind: SnackbarKind.SUCCESS
- }));
- }
-
- getSnippet = ({ apiHost, currentToken }: CurrentTokenDialogData) =>
- `HISTIGNORE=$HISTIGNORE:'export ARVADOS_API_TOKEN=*'
-export ARVADOS_API_TOKEN=${currentToken}
-export ARVADOS_API_HOST=${apiHost}
-unset ARVADOS_API_HOST_INSECURE`
-
- render() {
- const { classes, open, closeDialog, ...data } = this.props;
-
- return <Dialog
- open={open}
- onClose={closeDialog}
- fullWidth={true}
- maxWidth='md'>
- <DialogTitle>Current Token</DialogTitle>
- <DialogContent>
- <Typography paragraph={true}>
- The Arvados API token is a secret key that enables the Arvados SDKs to access Arvados with the proper permissions.
- <Typography component='span'>
- For more information see
- <a href='http://doc.arvados.org/user/reference/api-tokens.html' target='blank' className={classes.link}>
- Getting an API token.
- </a>
- </Typography>
- </Typography>
- <Typography paragraph={true}>
- Paste the following lines at a shell prompt to set up the necessary environment for Arvados SDKs to authenticate to your account.
- </Typography>
- <DefaultCodeSnippet lines={[this.getSnippet(data)]} />
- <CopyToClipboard text={this.getSnippet(data)} onCopy={() => this.onCopy('Token copied to clipboard')}>
- <Button
- color="primary"
- size="small"
- variant="contained"
- className={classes.copyButton}
- >
- COPY TO CLIPBOARD
- </Button>
- </CopyToClipboard>
- <Typography >
- Arvados
- <a href='http://doc.arvados.org/user/reference/api-tokens.html' target='blank' className={classes.link}>virtual machines</a>
- do this for you automatically. This setup is needed only when you use the API remotely (e.g., from your own workstation).
- </Typography>
- </DialogContent>
- <DialogActions>
- <Button onClick={closeDialog} className={classes.button} color="primary">CLOSE</Button>
- </DialogActions>
- </Dialog>;
- }
-}
-
-export const CurrentTokenDialog =
- withStyles(styles)(
- connect(getCurrentTokenDialogData)(
- withDialog(CURRENT_TOKEN_DIALOG_NAME)(CurrentTokenDialogComponent)));
-
import { DropdownMenu } from "~/components/dropdown-menu/dropdown-menu";
import { UserPanelIcon } from "~/components/icon/icon";
import { DispatchProp, connect } from 'react-redux';
-import { authActions } from '~/store/auth/auth-action';
+import { authActions, getNewExtraToken } from '~/store/auth/auth-action';
import { RootState } from "~/store/store";
-import { openCurrentTokenDialog } from '~/store/current-token-dialog/current-token-dialog-actions';
+import { openTokenDialog } from '~/store/token-dialog/token-dialog-actions';
import { openRepositoriesPanel } from "~/store/repositories/repositories-actions";
import {
navigateToSiteManager,
{user.isActive ? <>
<MenuItem onClick={() => dispatch(openUserVirtualMachines())}>Virtual Machines</MenuItem>
{!user.isAdmin && <MenuItem onClick={() => dispatch(openRepositoriesPanel())}>Repositories</MenuItem>}
- <MenuItem onClick={() => dispatch(openCurrentTokenDialog)}>Current token</MenuItem>
+ <MenuItem onClick={() => {
+ dispatch<any>(getNewExtraToken(true));
+ dispatch(openTokenDialog);
+ }}>Get API token</MenuItem>
<MenuItem onClick={() => dispatch(navigateToSshKeysUser)}>Ssh Keys</MenuItem>
<MenuItem onClick={() => dispatch(navigateToSiteManager)}>Site Manager</MenuItem>
<MenuItem onClick={() => dispatch(navigateToMyAccount)}>My account</MenuItem>
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { Button } from '@material-ui/core';
+import { mount, configure } from 'enzyme';
+import * as Adapter from 'enzyme-adapter-react-16';
+import * as CopyToClipboard from 'react-copy-to-clipboard';
+import { TokenDialogComponent } from './token-dialog';
+
+configure({ adapter: new Adapter() });
+
+jest.mock('toggle-selection', () => () => () => null);
+
+describe('<CurrentTokenDialog />', () => {
+ let props;
+ let wrapper;
+
+ beforeEach(() => {
+ props = {
+ classes: {},
+ token: 'xxxtokenxxx',
+ apiHost: 'example.com',
+ open: true,
+ dispatch: jest.fn(),
+ };
+ });
+
+ describe('Get API Token dialog', () => {
+ beforeEach(() => {
+ wrapper = mount(<TokenDialogComponent {...props} />);
+ });
+
+ it('should include API host and token', () => {
+ expect(wrapper.html()).toContain('export ARVADOS_API_HOST=example.com');
+ expect(wrapper.html()).toContain('export ARVADOS_API_TOKEN=xxxtokenxxx');
+ });
+
+ it('should show the token expiration if present', () => {
+ expect(props.tokenExpiration).toBeUndefined();
+ expect(wrapper.html()).not.toContain('Expires at:');
+
+ const someDate = '2140-01-01T00:00:00.000Z'
+ props.tokenExpiration = new Date(someDate);
+ wrapper = mount(<TokenDialogComponent {...props} />);
+ expect(wrapper.html()).toContain('Expires at:');
+ });
+
+ it('should show a create new token button when allowed', () => {
+ expect(props.canCreateNewTokens).toBeFalsy();
+ expect(wrapper.html()).not.toContain('GET NEW TOKEN');
+
+ props.canCreateNewTokens = true;
+ wrapper = mount(<TokenDialogComponent {...props} />);
+ expect(wrapper.html()).toContain('GET NEW TOKEN');
+ });
+ });
+
+ describe('copy to clipboard button', () => {
+ beforeEach(() => {
+ wrapper = mount(<TokenDialogComponent {...props} />);
+ });
+
+ it('should copy API TOKEN to the clipboard', () => {
+ // when
+ wrapper.find(CopyToClipboard).find(Button).simulate('click');
+
+ // and
+ expect(props.dispatch).toHaveBeenCalledWith({
+ payload: {
+ hideDuration: 2000,
+ kind: 1,
+ message: 'Shell code block copied',
+ },
+ type: 'OPEN_SNACKBAR',
+ });
+ });
+ });
+});
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import {
+ Dialog,
+ DialogActions,
+ DialogTitle,
+ DialogContent,
+ WithStyles,
+ withStyles,
+ StyleRulesCallback,
+ Button,
+ Typography
+} from '@material-ui/core';
+import * as CopyToClipboard from 'react-copy-to-clipboard';
+import { ArvadosTheme } from '~/common/custom-theme';
+import { withDialog } from '~/store/dialog/with-dialog';
+import { WithDialogProps } from '~/store/dialog/with-dialog';
+import { connect, DispatchProp } from 'react-redux';
+import {
+ TokenDialogData,
+ getTokenDialogData,
+ TOKEN_DIALOG_NAME,
+} from '~/store/token-dialog/token-dialog-actions';
+import { DefaultCodeSnippet } from '~/components/default-code-snippet/default-code-snippet';
+import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
+import { getNewExtraToken } from '~/store/auth/auth-action';
+import { DetailsAttribute } from '~/components/details-attribute/details-attribute';
+
+type CssRules = 'link' | 'paper' | 'button' | 'actionButton' | 'codeBlock';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+ link: {
+ color: theme.palette.primary.main,
+ textDecoration: 'none',
+ margin: '0px 4px'
+ },
+ paper: {
+ padding: theme.spacing.unit,
+ marginBottom: theme.spacing.unit * 2,
+ backgroundColor: theme.palette.grey["200"],
+ border: `1px solid ${theme.palette.grey["300"]}`
+ },
+ button: {
+ fontSize: '0.8125rem',
+ fontWeight: 600
+ },
+ actionButton: {
+ boxShadow: 'none',
+ marginTop: theme.spacing.unit * 2,
+ marginBottom: theme.spacing.unit * 2,
+ marginRight: theme.spacing.unit * 2,
+ },
+ codeBlock: {
+ fontSize: '0.8125rem',
+ },
+});
+
+type TokenDialogProps = TokenDialogData & WithDialogProps<{}> & WithStyles<CssRules> & DispatchProp;
+
+export class TokenDialogComponent extends React.Component<TokenDialogProps> {
+ onCopy = (message: string) => {
+ this.props.dispatch(snackbarActions.OPEN_SNACKBAR({
+ message,
+ hideDuration: 2000,
+ kind: SnackbarKind.SUCCESS
+ }));
+ }
+
+ onGetNewToken = async () => {
+ const newToken = await this.props.dispatch<any>(getNewExtraToken());
+ if (newToken) {
+ this.props.dispatch(snackbarActions.OPEN_SNACKBAR({
+ message: 'New token retrieved',
+ hideDuration: 2000,
+ kind: SnackbarKind.SUCCESS
+ }));
+ } else {
+ this.props.dispatch(snackbarActions.OPEN_SNACKBAR({
+ message: 'Creating new tokens is not allowed',
+ hideDuration: 2000,
+ kind: SnackbarKind.WARNING
+ }));
+ }
+ }
+
+ getSnippet = ({ apiHost, token }: TokenDialogData) =>
+ `HISTIGNORE=$HISTIGNORE:'export ARVADOS_API_TOKEN=*'
+export ARVADOS_API_TOKEN=${token}
+export ARVADOS_API_HOST=${apiHost}
+unset ARVADOS_API_HOST_INSECURE`
+
+ render() {
+ const { classes, open, closeDialog, ...data } = this.props;
+ const tokenExpiration = data.tokenExpiration
+ ? data.tokenExpiration.toLocaleString()
+ : `This token does not have an expiration date`;
+
+ return <Dialog
+ open={open}
+ onClose={closeDialog}
+ fullWidth={true}
+ maxWidth='md'>
+ <DialogTitle>Get API Token</DialogTitle>
+ <DialogContent>
+ <Typography paragraph={true}>
+ The Arvados API token is a secret key that enables the Arvados SDKs to access Arvados with the proper permissions.
+ <Typography component='span'>
+ For more information see
+ <a href='http://doc.arvados.org/user/reference/api-tokens.html' target='blank' className={classes.link}>
+ Getting an API token.
+ </a>
+ </Typography>
+ </Typography>
+ <Typography paragraph={true}>
+ <DetailsAttribute label='API Host' value={data.apiHost} copyValue={data.apiHost} />
+ <DetailsAttribute label='API Token' value={data.token} copyValue={data.token} />
+ <DetailsAttribute label='Token expiration' value={tokenExpiration} />
+ { this.props.canCreateNewTokens && <Button
+ onClick={() => this.onGetNewToken()}
+ color="primary"
+ size="small"
+ variant="contained"
+ className={classes.actionButton}
+ >
+ GET NEW TOKEN
+ </Button> }
+ </Typography>
+ <Typography paragraph={true}>
+ Paste the following lines at a shell prompt to set up the necessary environment for Arvados SDKs to authenticate to your account.
+ </Typography>
+ <DefaultCodeSnippet className={classes.codeBlock} lines={[this.getSnippet(data)]} />
+ <CopyToClipboard text={this.getSnippet(data)} onCopy={() => this.onCopy('Shell code block copied')}>
+ <Button
+ color="primary"
+ size="small"
+ variant="contained"
+ className={classes.actionButton}
+ >
+ COPY TO CLIPBOARD
+ </Button>
+ </CopyToClipboard>
+ <Typography>
+ Arvados
+ <a href='http://doc.arvados.org/user/reference/api-tokens.html' target='blank' className={classes.link}>virtual machines</a>
+ do this for you automatically. This setup is needed only when you use the API remotely (e.g., from your own workstation).
+ </Typography>
+ </DialogContent>
+ <DialogActions>
+ <Button onClick={closeDialog} className={classes.button} color="primary">CLOSE</Button>
+ </DialogActions>
+ </Dialog>;
+ }
+}
+
+export const TokenDialog =
+ withStyles(styles)(
+ connect(getTokenDialogData)(
+ withDialog(TOKEN_DIALOG_NAME)(TokenDialogComponent)));
+
export interface FileInputProps {
input: FileCommandInputParameter;
+ options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
}
-export const FileInput = ({ input }: FileInputProps) =>
+export const FileInput = ({ input, options }: FileInputProps) =>
<Field
name={input.id}
commandInput={input}
component={FileInputComponent}
format={format}
parse={parse}
+ {...{
+ options
+ }}
validate={getValidation(input)} />;
const format = (value?: File) => value ? value.basename : '';
}
const FileInputComponent = connect()(
- class FileInputComponent extends React.Component<GenericInputProps & DispatchProp, FileInputComponentState> {
+ class FileInputComponent extends React.Component<GenericInputProps & DispatchProp & {
+ options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
+ }, FileInputComponentState> {
state: FileInputComponentState = {
open: false,
};
pickerId={this.props.commandInput.id}
includeCollections
includeFiles
+ options={this.props.options}
toggleItemActive={this.setFile} />
</DialogContent>
<DialogActions>
return <StringInput input={input as StringCommandInputParameter} />;
case isPrimitiveOfType(input, CWLType.FILE):
- return <FileInput input={input as FileCommandInputParameter} />;
+ return <FileInput options={{ showOnlyOwned: false, showOnlyWritable: false }} input={input as FileCommandInputParameter} />;
case isPrimitiveOfType(input, CWLType.DIRECTORY):
return <DirectoryInput input={input as DirectoryCommandInputParameter} />;
import { ArvadosTheme } from '~/common/custom-theme';
import { ContextMenu } from "~/views-components/context-menu/context-menu";
import { FavoritePanel } from "../favorite-panel/favorite-panel";
-import { CurrentTokenDialog } from '~/views-components/current-token-dialog/current-token-dialog';
+import { TokenDialog } from '~/views-components/token-dialog/token-dialog';
import { RichTextEditorDialog } from '~/views-components/rich-text-editor-dialog/rich-text-editor-dialog';
import { Snackbar } from '~/views-components/snackbar/snackbar';
import { CollectionPanel } from '../collection-panel/collection-panel';
<CreateRepositoryDialog />
<CreateSshKeyDialog />
<CreateUserDialog />
- <CurrentTokenDialog />
+ <TokenDialog />
<FileRemoveDialog />
<FilesUploadCollectionDialog />
<GroupAttributesDialog />