* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { generateToken, getTokens } from '../../api/user-tokens';
+import Link from '../../components/common/Link';
import { Button } from '../../components/controls/buttons';
+import { ClipboardButton } from '../../components/controls/clipboard';
import { whenLoggedIn } from '../../components/hoc/whenLoggedIn';
+import CheckIcon from '../../components/icons/CheckIcon';
import { translate, translateWithParameters } from '../../helpers/l10n';
import { portIsValid, sendUserToken } from '../../helpers/sonarlint';
import {
getAvailableExpirationOptions,
getNextTokenName
} from '../../helpers/tokens';
+import { NewUserToken, TokenExpiration } from '../../types/token';
import { LoggedInUser } from '../../types/users';
import './SonarLintConnection.css';
+enum Status {
+ request,
+ tokenError,
+ connectionError,
+ success
+}
+
interface Props {
currentUser: LoggedInUser;
}
+const TOKEN_PREFIX = 'SonarLint';
+
const getNextAvailableTokenName = async (login: string, tokenNameBase: string) => {
const tokens = await getTokens(login);
const options = await getAvailableExpirationOptions();
const maxOption = options[options.length - 1];
- return maxOption.value ? computeTokenExpirationDate(maxOption.value) : undefined;
+ return computeTokenExpirationDate(maxOption.value || TokenExpiration.OneYear);
}
export function SonarLintConnection({ currentUser }: Props) {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
- const [success, setSuccess] = React.useState(false);
- const [error, setError] = React.useState('');
- const [newTokenName, setNewTokenName] = React.useState('');
+ const [status, setStatus] = React.useState(Status.request);
+ const [newToken, setNewToken] = React.useState<NewUserToken | undefined>(undefined);
const port = parseInt(searchParams.get('port') ?? '0', 10);
const ideName = searchParams.get('ideName') ?? translate('sonarlint-connection.unspecified-ide');
const { login } = currentUser;
const authorize = React.useCallback(async () => {
- const newTokenName = await getNextAvailableTokenName(login, `sonarlint-${ideName}`);
+ const newTokenName = await getNextAvailableTokenName(login, `${TOKEN_PREFIX}-${ideName}`);
const expirationDate = await computeExpirationDate();
- const token = await generateToken({ name: newTokenName, login, expirationDate });
+ const token = await generateToken({ name: newTokenName, login, expirationDate }).catch(
+ () => undefined
+ );
+
+ if (!token) {
+ setStatus(Status.tokenError);
+ return;
+ }
+
+ setNewToken(token);
try {
await sendUserToken(port, token);
- setSuccess(true);
- setNewTokenName(newTokenName);
- } catch (error) {
- if (error instanceof Error) {
- setError(error.message);
- } else {
- setError('-');
- }
+ setStatus(Status.success);
+ } catch (_) {
+ setStatus(Status.connectionError);
}
}, [port, ideName, login]);
<div className="sonarlint-connection-page">
<div className="sonarlint-connection-content boxed-group">
<div className="boxed-group-inner text-center">
- <h1 className="big-spacer-bottom">{translate('sonarlint-connection.title')}</h1>
- {error && (
+ {status === Status.request && (
<>
- <p className="big big-spacer-bottom">{translate('sonarlint-connection.error')}</p>
- <p className="monospaced huge-spacer-bottom">{error}</p>
+ <h1 className="big-spacer-top big-spacer-bottom">
+ {translate('sonarlint-connection.request.title')}
+ </h1>
+ <img
+ alt=""
+ aria-hidden={true}
+ className="big-spacer-top big-spacer-bottom"
+ src="/images/SonarLint-connection-request.png"
+ />
+ <p className="big big-spacer-top big-spacer-bottom">
+ {translateWithParameters('sonarlint-connection.request.description', ideName)}
+ </p>
+ <p className="big huge-spacer-bottom">
+ {translate('sonarlint-connection.request.description2')}
+ </p>
+
+ <Button className="big-spacer-bottom" onClick={authorize}>
+ <CheckIcon className="spacer-right" />
+ {translate('sonarlint-connection.request.action')}
+ </Button>
</>
)}
- {success && (
- <p className="big">
- {translateWithParameters('sonarlint-connection.success', newTokenName)}
- </p>
- )}
- {!error && !success && (
+ {status === Status.tokenError && (
<>
- <p className="big big-spacer-bottom">
- {translateWithParameters('sonarlint-connection.description', ideName)}
+ <img
+ alt=""
+ aria-hidden={true}
+ className="big-spacer-top big-spacer-bottom padded-top"
+ src="/images/cross.svg"
+ />
+ <h1 className="big-spacer-bottom">
+ {translate('sonarlint-connection.token-error.title')}
+ </h1>
+ <p className="big big-spacer-top big-spacer-bottom">
+ {translate('sonarlint-connection.token-error.description')}
</p>
<p className="big huge-spacer-bottom">
- {translate('sonarlint-connection.description2')}
+ <FormattedMessage
+ id="sonarlint-connection.token-error.description2"
+ defaultMessage={translate('sonarlint-connection.token-error.description2')}
+ values={{
+ link: (
+ <Link to="/account/security">
+ {translate('sonarlint-connection.token-error.description2.link')}
+ </Link>
+ )
+ }}
+ />
</p>
+ </>
+ )}
- <Button className="big-spacer-bottom" onClick={authorize}>
- {translate('sonarlint-connection.action')}
- </Button>
+ {status === Status.connectionError && newToken && (
+ <>
+ <img
+ alt=""
+ aria-hidden={true}
+ className="big-spacer-top big-spacer-bottom padded-top"
+ src="/images/check.svg"
+ />
+ <h1 className="big-spacer-bottom">
+ {translate('sonarlint-connection.connection-error.title')}
+ </h1>
+ <p className="big big-spacer-top big-spacer-bottom">
+ {translate('sonarlint-connection.connection-error.description')}
+ </p>
+ <div className="display-flex-center">
+ <span className="field-label">
+ {translate('sonarlint-connection.connection-error.token-name')}
+ </span>
+ {newToken.name}
+ </div>
+ <hr />
+ <div className="display-flex-center">
+ <span className="field-label">
+ {translate('sonarlint-connection.connection-error.token-value')}
+ </span>
+ <span className="sonarlint-token-value">{newToken.token}</span>
+ <ClipboardButton className="big-spacer-left" copyValue={newToken.token} />
+ </div>
+ <div className="big-spacer-top">
+ <strong>{translate('sonarlint-connection.connection-error.next-steps')}</strong>
+ </div>
+ <ol className="big-spacer-top big-spacer-bottom">
+ <li>{translate('sonarlint-connection.connection-error.step1')}</li>
+ <li>{translate('sonarlint-connection.connection-error.step2')}</li>
+ </ol>
+ </>
+ )}
+
+ {status === Status.success && newToken && (
+ <>
+ <h1 className="big-spacer-top big-spacer-bottom">
+ {translate('sonarlint-connection.success.title')}
+ </h1>
+ <img
+ alt=""
+ aria-hidden={true}
+ className="big-spacer-bottom"
+ src="/images/SonarLint-connection-ok.png"
+ />
+ <p className="big big-spacer-top big-spacer-bottom">
+ {translateWithParameters('sonarlint-connection.success.description', newToken.name)}
+ </p>
+ <div className="big-spacer-top">
+ <strong>{translate('sonarlint-connection.success.last-step')}</strong>
+ </div>
+ <div className="big-spacer-top big-spacer-bottom">
+ {translate('sonarlint-connection.success.step')}
+ </div>
</>
)}
</div>
renderSonarLintConnection();
expect(
- await screen.findByRole('heading', { name: 'sonarlint-connection.title' })
+ await screen.findByRole('heading', { name: 'sonarlint-connection.request.title' })
).toBeInTheDocument();
- await user.click(screen.getByRole('button', { name: 'sonarlint-connection.action' }));
+ await user.click(screen.getByRole('button', { name: 'sonarlint-connection.request.action' }));
expect(
- await screen.findByText(
- 'sonarlint-connection.success.sonarlint-sonarlint-connection.unspecified-ide'
- )
+ await screen.findByText('sonarlint-connection.success.description', { exact: false })
).toBeInTheDocument();
});
-it('should handle errors on binding', async () => {
- (sendUserToken as jest.Mock).mockRejectedValueOnce(new Error('error message'));
+it('should handle token generation errors', async () => {
+ tokenMock.failNextTokenGeneration();
const user = userEvent.setup();
renderSonarLintConnection();
- await user.click(await screen.findByRole('button', { name: 'sonarlint-connection.action' }));
+ await user.click(
+ await screen.findByRole('button', { name: 'sonarlint-connection.request.action' })
+ );
- expect(await screen.findByText('sonarlint-connection.error')).toBeInTheDocument();
- expect(await screen.findByText('error message')).toBeInTheDocument();
+ expect(
+ await screen.findByText('sonarlint-connection.token-error.description')
+ ).toBeInTheDocument();
+});
+
+it('should handle connection errors', async () => {
+ (sendUserToken as jest.Mock).mockRejectedValueOnce(new Error(''));
+
+ const user = userEvent.setup();
+ renderSonarLintConnection();
+
+ await user.click(
+ await screen.findByRole('button', { name: 'sonarlint-connection.request.action' })
+ );
+
+ expect(
+ await screen.findByText('sonarlint-connection.connection-error.description')
+ ).toBeInTheDocument();
+
+ const tokenValue = tokenMock.getLastToken()?.token ?? '';
+ expect(await screen.findByText(tokenValue)).toBeInTheDocument();
});
it('should require authentication if user is not logged in', () => {
#
#------------------------------------------------------------------------------
-sonarlint-connection.title=SonarLint binding
-sonarlint-connection.description=An instance of SonarLint for {0} requests access to this SonarQube instance.
+sonarlint-connection.request.title=Allow SonarLint connection?
+sonarlint-connection.request.description=SonarLint for {0} is requesting access to SonarQube.
+sonarlint-connection.request.description2=Do you allow SonarLint to connect? This will create a token and share it with SonarLint.
+sonarlint-connection.request.action=Allow connection
+
+sonarlint-connection.token-error.title=Token generation failed
+sonarlint-connection.token-error.description=SonarQube was not able to generate a token.
+sonarlint-connection.token-error.description2=Go back to your IDE and start again, or go to the {link} of your SonarQube account to create a new user token manually.
+sonarlint-connection.token-error.description2.link=Security section
+
+sonarlint-connection.connection-error.title=Token created
+sonarlint-connection.connection-error.description=The following token was created:
+sonarlint-connection.connection-error.token-name=Token name
+sonarlint-connection.connection-error.token-value=Token value
+sonarlint-connection.connection-error.next-steps=Next steps
+sonarlint-connection.connection-error.step1=Copy the above token.
+sonarlint-connection.connection-error.step2=Go back to your IDE and paste the token in SonarLint.
+
+sonarlint-connection.success.title=SonarLint connection is almost ready!
+sonarlint-connection.success.description=A new '{0}' token was created and sent to SonarLint in your IDE.
+sonarlint-connection.success.last-step=Last step
+sonarlint-connection.success.step=Go back to your IDE to complete the setup.
+
sonarlint-connection.unspecified-ide=an unspecified IDE
-sonarlint-connection.description2=Click on the button below to allow it to connect to this SonarQube instance. This will create a token and share it with SonarLint.
-sonarlint-connection.action=Allow SonarLint to connect to this instance
-sonarlint-connection.error=Something went wrong. Make sure your IDE is still running and try again.
-sonarlint-connection.success=A new '{0}' token was created and successfully sent to SonarLint in your IDE.
#------------------------------------------------------------------------------
#