aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/src/main
diff options
context:
space:
mode:
Diffstat (limited to 'server/sonar-web/src/main')
-rw-r--r--server/sonar-web/src/main/js/api/user-tokens.ts20
-rw-r--r--server/sonar-web/src/main/js/app/types.d.ts12
-rw-r--r--server/sonar-web/src/main/js/apps/tutorials/components/TokenStep.tsx6
-rw-r--r--server/sonar-web/src/main/js/apps/users/UsersList.tsx1
-rw-r--r--server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/UsersList-test.tsx.snap5
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx7
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/TokensFormItem.tsx16
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx6
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/__tests__/TokensForm-test.tsx83
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/__tests__/TokensFormItem-test.tsx65
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/__tests__/UserListItem-test.tsx24
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/TokensForm-test.tsx.snap168
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/TokensFormItem-test.tsx.snap69
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UserListItem-test.tsx.snap86
-rw-r--r--server/sonar-web/src/main/js/components/intl/DateFromNowHourPrecision.tsx56
-rw-r--r--server/sonar-web/src/main/js/helpers/dates.ts5
16 files changed, 591 insertions, 38 deletions
diff --git a/server/sonar-web/src/main/js/api/user-tokens.ts b/server/sonar-web/src/main/js/api/user-tokens.ts
index a74c9187d4d..fd446a35684 100644
--- a/server/sonar-web/src/main/js/api/user-tokens.ts
+++ b/server/sonar-web/src/main/js/api/user-tokens.ts
@@ -20,29 +20,15 @@
import { getJSON, postJSON, post } from '../helpers/request';
import throwGlobalError from '../app/utils/throwGlobalError';
-export interface UserToken {
- name: string;
- createdAt: string;
-}
-
/** List tokens for given user login */
-export function getTokens(login: string): Promise<UserToken[]> {
+export function getTokens(login: string): Promise<T.UserToken[]> {
return getJSON('/api/user_tokens/search', { login }).then(r => r.userTokens, throwGlobalError);
}
-export interface NewToken {
- createdAt: string;
- login: string;
- name: string;
- token: string;
-}
-
-/** Generate a user token */
-export function generateToken(data: { name: string; login?: string }): Promise<NewToken> {
+export function generateToken(data: { name: string; login?: string }): Promise<T.NewUserToken> {
return postJSON('/api/user_tokens/generate', data).catch(throwGlobalError);
}
-/** Revoke a user token */
-export function revokeToken(data: { name: string; login?: string }): Promise<void | Response> {
+export function revokeToken(data: { name: string; login?: string }) {
return post('/api/user_tokens/revoke', data).catch(throwGlobalError);
}
diff --git a/server/sonar-web/src/main/js/app/types.d.ts b/server/sonar-web/src/main/js/app/types.d.ts
index 52e7ee1fd13..a568d2dbcc1 100644
--- a/server/sonar-web/src/main/js/app/types.d.ts
+++ b/server/sonar-web/src/main/js/app/types.d.ts
@@ -859,6 +859,7 @@ declare namespace T {
externalIdentity?: string;
externalProvider?: string;
groups?: string[];
+ lastConnectionDate?: string;
local: boolean;
login: string;
name: string;
@@ -866,6 +867,17 @@ declare namespace T {
tokensCount?: number;
}
+ export interface UserToken {
+ name: string;
+ createdAt: string;
+ lastConnectionDate?: string;
+ }
+
+ export interface NewUserToken extends UserToken {
+ login: string;
+ token: string;
+ }
+
export type Visibility = 'public' | 'private';
export interface Webhook {
diff --git a/server/sonar-web/src/main/js/apps/tutorials/components/TokenStep.tsx b/server/sonar-web/src/main/js/apps/tutorials/components/TokenStep.tsx
index 0eb8a9714c6..6c92ab9c35b 100644
--- a/server/sonar-web/src/main/js/apps/tutorials/components/TokenStep.tsx
+++ b/server/sonar-web/src/main/js/apps/tutorials/components/TokenStep.tsx
@@ -22,10 +22,10 @@ import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router';
import * as classNames from 'classnames';
import Step from './Step';
-import { getTokens, generateToken, revokeToken, UserToken } from '../../../api/user-tokens';
import AlertErrorIcon from '../../../components/icons-components/AlertErrorIcon';
import AlertSuccessIcon from '../../../components/icons-components/AlertSuccessIcon';
import { DeleteButton, SubmitButton, Button } from '../../../components/ui/buttons';
+import { getTokens, generateToken, revokeToken } from '../../../api/user-tokens';
import { translate } from '../../../helpers/l10n';
interface Props {
@@ -44,7 +44,7 @@ interface State {
selection: string;
tokenName?: string;
token?: string;
- tokens?: UserToken[];
+ tokens?: T.UserToken[];
}
export default class TokenStep extends React.PureComponent<Props, State> {
@@ -85,7 +85,7 @@ export default class TokenStep extends React.PureComponent<Props, State> {
getToken = () =>
this.state.selection === 'generate' ? this.state.token : this.state.existingToken;
- getUniqueTokenName = (tokens: UserToken[]) => {
+ getUniqueTokenName = (tokens: T.UserToken[]) => {
const { initialTokenName = '' } = this.props;
const hasToken = (name: string) => tokens.find(token => token.name === name) !== undefined;
diff --git a/server/sonar-web/src/main/js/apps/users/UsersList.tsx b/server/sonar-web/src/main/js/apps/users/UsersList.tsx
index 8c50efcd063..3c437e1212a 100644
--- a/server/sonar-web/src/main/js/apps/users/UsersList.tsx
+++ b/server/sonar-web/src/main/js/apps/users/UsersList.tsx
@@ -46,6 +46,7 @@ export default function UsersList({
<th />
<th className="nowrap" />
<th className="nowrap">{translate('my_profile.scm_accounts')}</th>
+ <th className="nowrap">{translate('users.last_connection')}</th>
{!organizationsEnabled && <th className="nowrap">{translate('my_profile.groups')}</th>}
<th className="nowrap">{translate('users.tokens')}</th>
<th className="nowrap">&nbsp;</th>
diff --git a/server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/UsersList-test.tsx.snap b/server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/UsersList-test.tsx.snap
index b3a9562ccdd..f91c28ee600 100644
--- a/server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/UsersList-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/UsersList-test.tsx.snap
@@ -22,6 +22,11 @@ exports[`should render correctly 1`] = `
<th
className="nowrap"
>
+ users.last_connection
+ </th>
+ <th
+ className="nowrap"
+ >
users.tokens
</th>
<th
diff --git a/server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx b/server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx
index fb4dcd102a5..c62c2f253de 100644
--- a/server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx
+++ b/server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx
@@ -20,9 +20,9 @@
import * as React from 'react';
import TokensFormItem from './TokensFormItem';
import TokensFormNewToken from './TokensFormNewToken';
-import { getTokens, generateToken, UserToken } from '../../../api/user-tokens';
import DeferredSpinner from '../../../components/common/DeferredSpinner';
import { SubmitButton } from '../../../components/ui/buttons';
+import { getTokens, generateToken } from '../../../api/user-tokens';
import { translate } from '../../../helpers/l10n';
interface Props {
@@ -35,7 +35,7 @@ interface State {
loading: boolean;
newToken?: { name: string; token: string };
newTokenName: string;
- tokens: UserToken[];
+ tokens: T.UserToken[];
}
export default class TokensForm extends React.PureComponent<Props, State> {
@@ -103,7 +103,7 @@ export default class TokensForm extends React.PureComponent<Props, State> {
}
};
- handleRevokeToken = (revokedToken: UserToken) => {
+ handleRevokeToken = (revokedToken: T.UserToken) => {
this.setState(
state => ({
tokens: state.tokens.filter(token => token.name !== revokedToken.name)
@@ -175,6 +175,7 @@ export default class TokensForm extends React.PureComponent<Props, State> {
<thead>
<tr>
<th>{translate('name')}</th>
+ <th>{translate('my_account.tokens_last_usage')}</th>
<th className="text-right">{translate('created')}</th>
<th />
</tr>
diff --git a/server/sonar-web/src/main/js/apps/users/components/TokensFormItem.tsx b/server/sonar-web/src/main/js/apps/users/components/TokensFormItem.tsx
index 2f39abec632..09673e108dc 100644
--- a/server/sonar-web/src/main/js/apps/users/components/TokensFormItem.tsx
+++ b/server/sonar-web/src/main/js/apps/users/components/TokensFormItem.tsx
@@ -18,18 +18,19 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
-import { revokeToken, UserToken } from '../../../api/user-tokens';
+import DateFormatter from '../../../components/intl/DateFormatter';
+import DateFromNowHourPrecision from '../../../components/intl/DateFromNowHourPrecision';
import DeferredSpinner from '../../../components/common/DeferredSpinner';
import Tooltip from '../../../components/controls/Tooltip';
-import DateFormatter from '../../../components/intl/DateFormatter';
import { Button } from '../../../components/ui/buttons';
-import { translate } from '../../../helpers/l10n';
import { limitComponentName } from '../../../helpers/path';
+import { revokeToken } from '../../../api/user-tokens';
+import { translate } from '../../../helpers/l10n';
interface Props {
login: string;
- onRevokeToken: (token: UserToken) => void;
- token: UserToken;
+ onRevokeToken: (token: T.UserToken) => void;
+ token: T.UserToken;
}
interface State {
@@ -75,12 +76,15 @@ export default class TokensFormItem extends React.PureComponent<Props, State> {
<span>{limitComponentName(token.name)}</span>
</Tooltip>
</td>
+ <td className="nowrap">
+ <DateFromNowHourPrecision date={token.lastConnectionDate} />
+ </td>
<td className="thin nowrap text-right">
<DateFormatter date={token.createdAt} long={true} />
</td>
<td className="thin nowrap text-right">
<DeferredSpinner loading={loading}>
- <i className="spinner-placeholder " />
+ <i className="spinner-placeholder" />
</DeferredSpinner>
<Button
className="button-red input-small spacer-left"
diff --git a/server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx b/server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx
index b4a45d85ba3..1c52666dd64 100644
--- a/server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx
+++ b/server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx
@@ -23,8 +23,9 @@ import UserActions from './UserActions';
import UserGroups from './UserGroups';
import UserListItemIdentity from './UserListItemIdentity';
import UserScmAccounts from './UserScmAccounts';
-import BulletListIcon from '../../../components/icons-components/BulletListIcon';
import Avatar from '../../../components/ui/Avatar';
+import BulletListIcon from '../../../components/icons-components/BulletListIcon';
+import DateFromNowHourPrecision from '../../../components/intl/DateFromNowHourPrecision';
import { ButtonIcon } from '../../../components/ui/buttons';
import { translate } from '../../../helpers/l10n';
@@ -59,6 +60,9 @@ export default class UserListItem extends React.PureComponent<Props, State> {
<td>
<UserScmAccounts scmAccounts={user.scmAccounts || []} />
</td>
+ <td>
+ <DateFromNowHourPrecision date={user.lastConnectionDate} />
+ </td>
{!organizationsEnabled && (
<td>
<UserGroups groups={user.groups || []} onUpdateUsers={onUpdateUsers} user={user} />
diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/TokensForm-test.tsx b/server/sonar-web/src/main/js/apps/users/components/__tests__/TokensForm-test.tsx
new file mode 100644
index 00000000000..71d9002127c
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/TokensForm-test.tsx
@@ -0,0 +1,83 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import * as React from 'react';
+import { shallow } from 'enzyme';
+import TokensForm from '../TokensForm';
+import { change, submit, waitAndUpdate } from '../../../../helpers/testUtils';
+import { generateToken, getTokens } from '../../../../api/user-tokens';
+
+jest.mock('../../../../api/user-tokens', () => ({
+ generateToken: jest.fn().mockResolvedValue({
+ name: 'baz',
+ createdAt: '2019-01-21T08:06:00+0100',
+ login: 'luke',
+ token: 'token_value'
+ }),
+ getTokens: jest.fn().mockResolvedValue([
+ {
+ name: 'foo',
+ createdAt: '2019-01-15T15:06:33+0100',
+ lastConnectionDate: '2019-01-18T15:06:33+0100'
+ },
+ { name: 'bar', createdAt: '2019-01-18T15:06:33+0100' }
+ ])
+}));
+
+beforeEach(() => {
+ (generateToken as jest.Mock).mockClear();
+ (getTokens as jest.Mock).mockClear();
+});
+
+it('should render correctly', async () => {
+ const wrapper = shallowRender();
+ expect(wrapper).toMatchSnapshot();
+ expect(getTokens).toHaveBeenCalledWith('luke');
+
+ await waitAndUpdate(wrapper);
+ expect(wrapper).toMatchSnapshot();
+});
+
+it('should create new tokens', async () => {
+ const wrapper = shallowRender();
+
+ await waitAndUpdate(wrapper);
+ expect(wrapper.find('TokensFormItem')).toHaveLength(2);
+ change(wrapper.find('input'), 'baz');
+ submit(wrapper.find('form'));
+
+ await waitAndUpdate(wrapper);
+ expect(generateToken).toHaveBeenCalledWith({ name: 'baz', login: 'luke' });
+ expect(wrapper.find('TokensFormItem')).toHaveLength(3);
+});
+
+it('should revoke tokens', async () => {
+ const updateTokensCount = jest.fn();
+ const wrapper = shallowRender({ updateTokensCount });
+
+ await waitAndUpdate(wrapper);
+ expect(wrapper.find('TokensFormItem')).toHaveLength(2);
+ wrapper.instance().handleRevokeToken({ createdAt: '2019-01-15T15:06:33+0100', name: 'foo' });
+ expect(updateTokensCount).toHaveBeenCalledWith('luke', 1);
+ expect(wrapper.find('TokensFormItem')).toHaveLength(1);
+});
+
+function shallowRender(props: Partial<TokensForm['props']> = {}) {
+ return shallow<TokensForm>(<TokensForm login="luke" updateTokensCount={jest.fn()} {...props} />);
+}
diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/TokensFormItem-test.tsx b/server/sonar-web/src/main/js/apps/users/components/__tests__/TokensFormItem-test.tsx
new file mode 100644
index 00000000000..1a65f7c4756
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/TokensFormItem-test.tsx
@@ -0,0 +1,65 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import * as React from 'react';
+import { shallow } from 'enzyme';
+import TokensFormItem from '../TokensFormItem';
+import { revokeToken } from '../../../../api/user-tokens';
+import { click, waitAndUpdate } from '../../../../helpers/testUtils';
+
+jest.mock('../../../../components/intl/DateFormatter');
+jest.mock('../../../../components/intl/DateFromNow');
+jest.mock('../../../../components/intl/DateTimeFormatter');
+
+jest.mock('../../../../api/user-tokens', () => ({
+ revokeToken: jest.fn().mockResolvedValue(undefined)
+}));
+
+const userToken: T.UserToken = {
+ name: 'foo',
+ createdAt: '2019-01-15T15:06:33+0100',
+ lastConnectionDate: '2019-01-18T15:06:33+0100'
+};
+
+beforeEach(() => {
+ (revokeToken as jest.Mock).mockClear();
+});
+
+it('should render correctly', () => {
+ expect(shallowRender()).toMatchSnapshot();
+});
+
+it('should revoke the token', async () => {
+ const onRevokeToken = jest.fn();
+ const wrapper = shallowRender({ onRevokeToken });
+ expect(wrapper.find('Button')).toMatchSnapshot();
+ click(wrapper.find('Button'));
+ expect(wrapper.find('Button')).toMatchSnapshot();
+ click(wrapper.find('Button'));
+ expect(wrapper.find('DeferredSpinner').prop('loading')).toBe(true);
+ await waitAndUpdate(wrapper);
+ expect(revokeToken).toHaveBeenCalledWith({ login: 'luke', name: 'foo' });
+ expect(onRevokeToken).toHaveBeenCalledWith(userToken);
+});
+
+function shallowRender(props: Partial<TokensFormItem['props']> = {}) {
+ return shallow(
+ <TokensFormItem login="luke" onRevokeToken={jest.fn()} token={userToken} {...props} />
+ );
+}
diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/UserListItem-test.tsx b/server/sonar-web/src/main/js/apps/users/components/__tests__/UserListItem-test.tsx
index 3b040e8a888..a6e895d5840 100644
--- a/server/sonar-web/src/main/js/apps/users/components/__tests__/UserListItem-test.tsx
+++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/UserListItem-test.tsx
@@ -22,33 +22,41 @@ import { shallow } from 'enzyme';
import { click } from '../../../../helpers/testUtils';
import UserListItem from '../UserListItem';
-const user = {
+jest.mock('../../../../components/intl/DateFromNow');
+jest.mock('../../../../components/intl/DateTimeFormatter');
+
+const user: T.User = {
+ active: true,
+ lastConnectionDate: '2019-01-18T15:06:33+0100',
+ local: false,
login: 'obi',
name: 'One',
- active: true,
- scmAccounts: [],
- local: false
+ scmAccounts: []
};
it('should render correctly', () => {
- expect(getWrapper()).toMatchSnapshot();
+ expect(shallowRender()).toMatchSnapshot();
+});
+
+it('should render correctly without last connection date', () => {
+ expect(shallowRender({})).toMatchSnapshot();
});
it('should display a change password button', () => {
expect(
- getWrapper({ organizationsEnabled: true })
+ shallowRender({ organizationsEnabled: true })
.find('UserGroups')
.exists()
).toBeFalsy();
});
it('should open the correct forms', () => {
- const wrapper = getWrapper();
+ const wrapper = shallowRender();
click(wrapper.find('.js-user-tokens'));
expect(wrapper.find('TokensFormModal').exists()).toBeTruthy();
});
-function getWrapper(props = {}) {
+function shallowRender(props: Partial<UserListItem['props']> = {}) {
return shallow(
<UserListItem
isCurrentUser={false}
diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/TokensForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/TokensForm-test.tsx.snap
new file mode 100644
index 00000000000..2a6ae4b6be1
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/TokensForm-test.tsx.snap
@@ -0,0 +1,168 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<Fragment>
+ <h3
+ className="spacer-bottom"
+ >
+ users.generate_tokens
+ </h3>
+ <form
+ autoComplete="off"
+ className="display-flex-center"
+ id="generate-token-form"
+ onSubmit={[Function]}
+ >
+ <input
+ className="spacer-right"
+ maxLength={100}
+ onChange={[Function]}
+ placeholder="users.enter_token_name"
+ required={true}
+ type="text"
+ value=""
+ />
+ <SubmitButton
+ className="js-generate-token"
+ disabled={true}
+ >
+ users.generate
+ </SubmitButton>
+ </form>
+ <table
+ className="data zebra big-spacer-top "
+ >
+ <thead>
+ <tr>
+ <th>
+ name
+ </th>
+ <th>
+ my_account.tokens_last_usage
+ </th>
+ <th
+ className="text-right"
+ >
+ created
+ </th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ <DeferredSpinner
+ customSpinner={
+ <tr>
+ <td>
+ <i
+ className="spinner"
+ />
+ </td>
+ </tr>
+ }
+ loading={true}
+ timeout={100}
+ >
+ <tr>
+ <td
+ className="note"
+ colSpan={3}
+ >
+ users.no_tokens
+ </td>
+ </tr>
+ </DeferredSpinner>
+ </tbody>
+ </table>
+</Fragment>
+`;
+
+exports[`should render correctly 2`] = `
+<Fragment>
+ <h3
+ className="spacer-bottom"
+ >
+ users.generate_tokens
+ </h3>
+ <form
+ autoComplete="off"
+ className="display-flex-center"
+ id="generate-token-form"
+ onSubmit={[Function]}
+ >
+ <input
+ className="spacer-right"
+ maxLength={100}
+ onChange={[Function]}
+ placeholder="users.enter_token_name"
+ required={true}
+ type="text"
+ value=""
+ />
+ <SubmitButton
+ className="js-generate-token"
+ disabled={true}
+ >
+ users.generate
+ </SubmitButton>
+ </form>
+ <table
+ className="data zebra big-spacer-top "
+ >
+ <thead>
+ <tr>
+ <th>
+ name
+ </th>
+ <th>
+ my_account.tokens_last_usage
+ </th>
+ <th
+ className="text-right"
+ >
+ created
+ </th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ <DeferredSpinner
+ customSpinner={
+ <tr>
+ <td>
+ <i
+ className="spinner"
+ />
+ </td>
+ </tr>
+ }
+ loading={false}
+ timeout={100}
+ >
+ <TokensFormItem
+ key="foo"
+ login="luke"
+ onRevokeToken={[Function]}
+ token={
+ Object {
+ "createdAt": "2019-01-15T15:06:33+0100",
+ "lastConnectionDate": "2019-01-18T15:06:33+0100",
+ "name": "foo",
+ }
+ }
+ />
+ <TokensFormItem
+ key="bar"
+ login="luke"
+ onRevokeToken={[Function]}
+ token={
+ Object {
+ "createdAt": "2019-01-18T15:06:33+0100",
+ "name": "bar",
+ }
+ }
+ />
+ </DeferredSpinner>
+ </tbody>
+ </table>
+</Fragment>
+`;
diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/TokensFormItem-test.tsx.snap b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/TokensFormItem-test.tsx.snap
new file mode 100644
index 00000000000..499fed7e30f
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/TokensFormItem-test.tsx.snap
@@ -0,0 +1,69 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<tr>
+ <td>
+ <Tooltip
+ overlay="foo"
+ >
+ <span>
+ foo
+ </span>
+ </Tooltip>
+ </td>
+ <td
+ className="nowrap"
+ >
+ <DateFromNowHourPrecision
+ date="2019-01-18T15:06:33+0100"
+ />
+ </td>
+ <td
+ className="thin nowrap text-right"
+ >
+ <DateFormatter
+ date="2019-01-15T15:06:33+0100"
+ long={true}
+ />
+ </td>
+ <td
+ className="thin nowrap text-right"
+ >
+ <DeferredSpinner
+ loading={false}
+ timeout={100}
+ >
+ <i
+ className="spinner-placeholder"
+ />
+ </DeferredSpinner>
+ <Button
+ className="button-red input-small spacer-left"
+ disabled={false}
+ onClick={[Function]}
+ >
+ users.tokens.revoke
+ </Button>
+ </td>
+</tr>
+`;
+
+exports[`should revoke the token 1`] = `
+<Button
+ className="button-red input-small spacer-left"
+ disabled={false}
+ onClick={[Function]}
+>
+ users.tokens.revoke
+</Button>
+`;
+
+exports[`should revoke the token 2`] = `
+<Button
+ className="button-red input-small spacer-left"
+ disabled={false}
+ onClick={[Function]}
+>
+ users.tokens.sure
+</Button>
+`;
diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UserListItem-test.tsx.snap b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UserListItem-test.tsx.snap
index d2c0e157903..8905a4b99af 100644
--- a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UserListItem-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UserListItem-test.tsx.snap
@@ -14,6 +14,7 @@ exports[`should render correctly 1`] = `
user={
Object {
"active": true,
+ "lastConnectionDate": "2019-01-18T15:06:33+0100",
"local": false,
"login": "obi",
"name": "One",
@@ -27,12 +28,96 @@ exports[`should render correctly 1`] = `
/>
</td>
<td>
+ <DateFromNowHourPrecision
+ date="2019-01-18T15:06:33+0100"
+ />
+ </td>
+ <td>
+ <UserGroups
+ groups={Array []}
+ onUpdateUsers={[MockFunction]}
+ user={
+ Object {
+ "active": true,
+ "lastConnectionDate": "2019-01-18T15:06:33+0100",
+ "local": false,
+ "login": "obi",
+ "name": "One",
+ "scmAccounts": Array [],
+ }
+ }
+ />
+ </td>
+ <td>
+ <ButtonIcon
+ className="js-user-tokens spacer-left button-small"
+ onClick={[Function]}
+ tooltip="users.update_tokens"
+ >
+ <BulletListIcon />
+ </ButtonIcon>
+ </td>
+ <td
+ className="thin nowrap text-right"
+ >
+ <UserActions
+ isCurrentUser={false}
+ onUpdateUsers={[MockFunction]}
+ user={
+ Object {
+ "active": true,
+ "lastConnectionDate": "2019-01-18T15:06:33+0100",
+ "local": false,
+ "login": "obi",
+ "name": "One",
+ "scmAccounts": Array [],
+ }
+ }
+ />
+ </td>
+</tr>
+`;
+
+exports[`should render correctly without last connection date 1`] = `
+<tr>
+ <td
+ className="thin nowrap"
+ >
+ <Connect(Avatar)
+ name="One"
+ size={36}
+ />
+ </td>
+ <UserListItemIdentity
+ user={
+ Object {
+ "active": true,
+ "lastConnectionDate": "2019-01-18T15:06:33+0100",
+ "local": false,
+ "login": "obi",
+ "name": "One",
+ "scmAccounts": Array [],
+ }
+ }
+ />
+ <td>
+ <UserScmAccounts
+ scmAccounts={Array []}
+ />
+ </td>
+ <td>
+ <DateFromNowHourPrecision
+ date="2019-01-18T15:06:33+0100"
+ />
+ </td>
+ <td>
<UserGroups
groups={Array []}
onUpdateUsers={[MockFunction]}
user={
Object {
"active": true,
+ "lastConnectionDate": "2019-01-18T15:06:33+0100",
"local": false,
"login": "obi",
"name": "One",
@@ -59,6 +144,7 @@ exports[`should render correctly 1`] = `
user={
Object {
"active": true,
+ "lastConnectionDate": "2019-01-18T15:06:33+0100",
"local": false,
"login": "obi",
"name": "One",
diff --git a/server/sonar-web/src/main/js/components/intl/DateFromNowHourPrecision.tsx b/server/sonar-web/src/main/js/components/intl/DateFromNowHourPrecision.tsx
new file mode 100644
index 00000000000..c8cad8cb0fe
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/intl/DateFromNowHourPrecision.tsx
@@ -0,0 +1,56 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import * as React from 'react';
+import { DateSource } from 'react-intl';
+import DateFromNow from './DateFromNow';
+import DateTimeFormatter from './DateTimeFormatter';
+import Tooltip from '../controls/Tooltip';
+import { differenceInHours } from '../../helpers/dates';
+import { translate } from '../../helpers/l10n';
+
+interface Props {
+ children?: (formattedDate: string) => React.ReactNode;
+ date?: DateSource;
+}
+
+export default class DateFromNowHourPrecision extends React.PureComponent<Props> {
+ render() {
+ const { children, date } = this.props;
+
+ let overrideDate: string | undefined;
+ if (!date) {
+ overrideDate = translate('never');
+ } else if (differenceInHours(Date.now(), date) < 1) {
+ overrideDate = translate('less_than_1_hour_ago');
+ }
+
+ if (overrideDate) {
+ return children ? children(overrideDate) : overrideDate;
+ }
+
+ return (
+ <Tooltip overlay={<DateTimeFormatter date={date!} />}>
+ <span>
+ <DateFromNow date={date!}>{children}</DateFromNow>
+ </span>
+ </Tooltip>
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/helpers/dates.ts b/server/sonar-web/src/main/js/helpers/dates.ts
index ee2a3cca7f9..45b3fc648f0 100644
--- a/server/sonar-web/src/main/js/helpers/dates.ts
+++ b/server/sonar-web/src/main/js/helpers/dates.ts
@@ -18,6 +18,7 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as _differenceInDays from 'date-fns/difference_in_days';
+import * as _differenceInHours from 'date-fns/difference_in_hours';
import * as _differenceInSeconds from 'date-fns/difference_in_seconds';
import * as _differenceInYears from 'date-fns/difference_in_years';
import * as _isSameDay from 'date-fns/is_same_day';
@@ -67,6 +68,10 @@ export function differenceInDays(dateLeft: ParsableDate, dateRight: ParsableDate
return _differenceInDays(dateLeft, dateRight);
}
+export function differenceInHours(dateLeft: ParsableDate, dateRight: ParsableDate): number {
+ return _differenceInHours(dateLeft, dateRight);
+}
+
export function differenceInSeconds(dateLeft: ParsableDate, dateRight: ParsableDate): number {
return _differenceInSeconds(dateLeft, dateRight);
}