]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-21482 User security page adopts the new UI
authorDavid Cho-Lerat <david.cho-lerat@sonarsource.com>
Wed, 24 Jan 2024 17:27:33 +0000 (18:27 +0100)
committersonartech <sonartech@sonarsource.com>
Tue, 30 Jan 2024 15:02:03 +0000 (15:02 +0000)
server/sonar-web/src/main/js/apps/account/Account.tsx
server/sonar-web/src/main/js/apps/account/__tests__/Account-it.tsx
server/sonar-web/src/main/js/apps/account/security/Security.tsx
server/sonar-web/src/main/js/apps/account/security/Tokens.tsx
server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx
server/sonar-web/src/main/js/components/common/ResetPasswordForm.tsx

index 0cbb0cfade625a1a21295c01d50b81af7ce928de..9af6a4ec10ff7054d345f0faf572902de5912f0f 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
 import { LargeCenteredLayout, PageContentFontWrapper, TopBar } from 'design-system';
 import * as React from 'react';
+import { createPortal } from 'react-dom';
 import { Helmet } from 'react-helmet-async';
 import { Outlet } from 'react-router-dom';
 import { useCurrentLoginUser } from '../../app/components/current-user/CurrentUserContext';
@@ -31,22 +33,35 @@ import UserCard from './components/UserCard';
 
 export default function Account() {
   const currentUser = useCurrentLoginUser();
+  const [portalAnchor, setPortalAnchor] = React.useState<Element | null>(null);
+
+  // Set portal anchor on mount
+  React.useEffect(() => {
+    setPortalAnchor(document.getElementById('component-nav-portal'));
+  }, []);
 
   const title = translate('my_account.page');
+
   return (
     <div id="account-page">
-      <header>
-        <TopBar>
-          <div className="sw-flex sw-items-center sw-gap-2 sw-pb-4">
-            <UserCard user={currentUser} />
-          </div>
-          <Nav />
-        </TopBar>
-      </header>
+      {portalAnchor &&
+        createPortal(
+          <header>
+            <TopBar>
+              <div className="sw-flex sw-items-center sw-gap-2 sw-pb-4">
+                <UserCard user={currentUser} />
+              </div>
+
+              <Nav />
+            </TopBar>
+          </header>,
+          portalAnchor,
+        )}
 
       <LargeCenteredLayout as="main">
         <PageContentFontWrapper className="sw-body-sm sw-py-8">
           <Suggestions suggestions="account" />
+
           <Helmet
             defaultTitle={title}
             defer={false}
@@ -55,7 +70,9 @@ export default function Account() {
               translate('my_account.page'),
             )}
           />
+
           <A11ySkipTarget anchor="account_main" />
+
           <Outlet />
         </PageContentFontWrapper>
       </LargeCenteredLayout>
index e3d1e1b39afd014e18c3caa839db360cbacf55f1..b9e31528a252f608a700eecd2482c6ac1d47d16b 100644 (file)
@@ -20,6 +20,8 @@
 import { screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup';
+import React from 'react';
+import { Outlet, Route } from 'react-router-dom';
 import selectEvent from 'react-select-event';
 import { getMyProjects, getScannableProjects } from '../../../api/components';
 import NotificationsMock from '../../../api/mocks/NotificationsMock';
@@ -176,7 +178,7 @@ it('should render the top menu', () => {
 
   expect(screen.getByText(name)).toBeInTheDocument();
 
-  expect(screen.getByRole('heading', { name: 'my_account.profile' })).toBeInTheDocument();
+  expect(screen.getByText('my_account.profile')).toBeInTheDocument();
   expect(screen.getByText('my_account.security')).toBeInTheDocument();
   expect(screen.getByText('my_account.notifications')).toBeInTheDocument();
   expect(screen.getByText('my_account.projects')).toBeInTheDocument();
@@ -271,7 +273,7 @@ describe('security page', () => {
         securityPagePath,
       );
 
-      expect(await screen.findByText('users.tokens')).toBeInTheDocument();
+      expect(await screen.findByText('users.tokens.generate')).toBeInTheDocument();
       await waitFor(() => expect(screen.getAllByRole('row')).toHaveLength(3)); // 2 tokens + header
 
       // Add the token
@@ -372,7 +374,7 @@ describe('security page', () => {
       securityPagePath,
     );
 
-    expect(await screen.findByText('users.tokens')).toBeInTheDocument();
+    expect(await screen.findByText('users.tokens.generate')).toBeInTheDocument();
 
     // expired token is flagged as such
     const expiredTokenRow = await screen.findByRole('row', { name: /expired token/ });
@@ -667,5 +669,22 @@ function getProjectBlock(projectName: string) {
 }
 
 function renderAccountApp(currentUser: CurrentUser, navigateTo?: string) {
-  renderAppRoutes('account', routes, { currentUser, navigateTo });
+  renderAppRoutes(
+    'account',
+    () => (
+      <Route
+        path="/"
+        element={
+          <>
+            <div id="component-nav-portal" />
+
+            <Outlet />
+          </>
+        }
+      >
+        {routes()}
+      </Route>
+    ),
+    { currentUser, navigateTo },
+  );
 }
index 39f270f1818f8c6d413afb1254435d6d50500e4c..fa7e912e9a2ec6f760691250ad517f310f97ed7f 100644 (file)
@@ -17,6 +17,8 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
+import { PageTitle, SubHeading } from 'design-system';
 import * as React from 'react';
 import { Helmet } from 'react-helmet-async';
 import { useCurrentLoginUser } from '../../../app/components/current-user/CurrentUserContext';
@@ -27,15 +29,21 @@ import Tokens from './Tokens';
 export default function Security() {
   const currentUser = useCurrentLoginUser();
   return (
-    <div className="account-body account-container">
+    <>
       <Helmet defer={false} title={translate('my_account.security')} />
+
       <Tokens login={currentUser.login} />
+
       {currentUser.local && (
-        <section className="boxed-group">
-          <h2 className="spacer-bottom">{translate('my_profile.password.title')}</h2>
-          <ResetPasswordForm className="boxed-group-inner" user={currentUser} />
-        </section>
+        <SubHeading as="section">
+          <PageTitle
+            className="sw-heading-md sw-my-6"
+            text={translate('my_profile.password.title')}
+          />
+
+          <ResetPasswordForm user={currentUser} />
+        </SubHeading>
       )}
-    </div>
+    </>
   );
 }
index 7c6804fc214befa8200e1caa930ec3dbbdd3e6f0..27d6728c85e6a96201b748e40a9ca5e443628740 100644 (file)
@@ -17,6 +17,8 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
+import { Title } from 'design-system';
 import * as React from 'react';
 import InstanceMessage from '../../../components/common/InstanceMessage';
 import { translate } from '../../../helpers/l10n';
@@ -26,17 +28,18 @@ interface Props {
   login: string;
 }
 
-export default function Tokens({ login }: Props) {
+export default function Tokens({ login }: Readonly<Props>) {
   return (
-    <div className="boxed-group">
-      <h2>{translate('users.tokens')}</h2>
-      <div className="boxed-group-inner">
-        <div className="big-spacer-bottom big-spacer-right markdown">
+    <>
+      <Title>{translate('my_account.security')}</Title>
+
+      <div>
+        <div className="sw-body-md sw-mb-4 sw-mr-4">
           <InstanceMessage message={translate('my_account.tokens_description')} />
         </div>
 
         <TokensForm deleteConfirmation="modal" login={login} displayTokenTypeInput />
       </div>
-    </div>
+    </>
   );
 }
index 539fc0feeed6ea6de9cae89001bf0ade6daa2c09..6961767c107c4542ab9ccc134e790852efbccf37 100644 (file)
@@ -25,6 +25,7 @@ import {
   InputField,
   InputSelect,
   Spinner,
+  SubHeading,
   Table,
   TableRow,
 } from 'design-system';
@@ -194,7 +195,9 @@ export function TokensForm(props: Readonly<Props>) {
 
   return (
     <>
-      <h3 className="sw-mb-2">{translate('users.tokens.generate')}</h3>
+      <GreySeparator className="sw-mb-4 sw-mt-6" />
+
+      <SubHeading as="h2">{translate('users.tokens.generate')}</SubHeading>
 
       <form autoComplete="off" className="sw-flex sw-items-center" onSubmit={handleGenerateToken}>
         <div className="sw-flex sw-flex-col sw-mr-2">
@@ -284,14 +287,14 @@ export function TokensForm(props: Readonly<Props>) {
 
       <GreySeparator className="sw-mb-4 sw-mt-6" />
 
-      <Table
-        className="sw-min-h-40 sw-w-full"
-        columnCount={COLUMN_WIDTHS.length}
-        columnWidths={COLUMN_WIDTHS}
-        header={tableHeader}
-        noHeaderTopBorder
-      >
-        <Spinner loading={loading}>
+      <Spinner loading={loading}>
+        <Table
+          className="sw-min-h-40 sw-w-full"
+          columnCount={COLUMN_WIDTHS.length}
+          columnWidths={COLUMN_WIDTHS}
+          header={tableHeader}
+          noHeaderTopBorder
+        >
           {tokens && tokens.length <= 0 ? (
             <TableRow>
               <ContentCell className="sw-body-lg" colSpan={7}>
@@ -308,8 +311,8 @@ export function TokensForm(props: Readonly<Props>) {
               />
             ))
           )}
-        </Spinner>
-      </Table>
+        </Table>
+      </Spinner>
     </>
   );
 }
index 6a7b36061f47c2f7312ba9379599c15887094488..b282209f32f644dce1da2a665afa915c16f5c29c 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
+import { ButtonPrimary, FlagMessage, FormField, InputField } from 'design-system';
 import * as React from 'react';
 import { changePassword } from '../../api/users';
-import { SubmitButton } from '../../components/controls/buttons';
-import { Alert } from '../../components/ui/Alert';
-import MandatoryFieldMarker from '../../components/ui/MandatoryFieldMarker';
 import MandatoryFieldsExplanation from '../../components/ui/MandatoryFieldsExplanation';
 import { translate } from '../../helpers/l10n';
 import { ChangePasswordResults, LoggedInUser } from '../../types/users';
 
 interface Props {
   className?: string;
-  user: LoggedInUser;
   onPasswordChange?: () => void;
+  user: LoggedInUser;
 }
 
-interface State {
-  errors?: string[];
-  success: boolean;
-}
+export default function ResetPasswordForm({
+  className,
+  onPasswordChange,
+  user: { login },
+}: Readonly<Props>) {
+  const [error, setError] = React.useState<string | undefined>(undefined);
+  const [oldPassword, setOldPassword] = React.useState('');
+  const [password, setPassword] = React.useState('');
+  const [passwordConfirmation, setPasswordConfirmation] = React.useState('');
+  const [success, setSuccess] = React.useState(false);
 
-export default class ResetPasswordForm extends React.Component<Props, State> {
-  oldPassword: HTMLInputElement | null = null;
-  password: HTMLInputElement | null = null;
-  passwordConfirmation: HTMLInputElement | null = null;
-  state: State = {
-    success: false,
-  };
+  const handleSuccessfulChange = () => {
+    setOldPassword('');
+    setPassword('');
+    setPasswordConfirmation('');
+    setSuccess(true);
 
-  handleSuccessfulChange = () => {
-    if (!this.oldPassword || !this.password || !this.passwordConfirmation) {
-      return;
-    }
-    this.oldPassword.value = '';
-    this.password.value = '';
-    this.passwordConfirmation.value = '';
-    this.setState({ success: true, errors: undefined });
-    if (this.props.onPasswordChange) {
-      this.props.onPasswordChange();
-    }
-  };
-
-  setErrors = (errors: string[]) => {
-    this.setState({ success: false, errors });
+    onPasswordChange?.();
   };
 
-  handleChangePassword = (event: React.FormEvent) => {
+  const handleChangePassword = (event: React.FormEvent) => {
     event.preventDefault();
-    if (!this.oldPassword || !this.password || !this.passwordConfirmation) {
-      return;
-    }
-    const { user } = this.props;
-    const previousPassword = this.oldPassword.value;
-    const password = this.password.value;
-    const passwordConfirmation = this.passwordConfirmation.value;
+
+    setError(undefined);
+    setSuccess(false);
 
     if (password !== passwordConfirmation) {
-      this.password.focus();
-      this.setErrors([translate('user.password_doesnt_match_confirmation')]);
+      setError(translate('user.password_doesnt_match_confirmation'));
     } else {
-      changePassword({ login: user.login, password, previousPassword })
-        .then(this.handleSuccessfulChange)
+      changePassword({ login, password, previousPassword: oldPassword })
+        .then(handleSuccessfulChange)
         .catch((result: ChangePasswordResults) => {
           if (result === ChangePasswordResults.OldPasswordIncorrect) {
-            this.setErrors([translate('user.old_password_incorrect')]);
+            setError(translate('user.old_password_incorrect'));
           } else if (result === ChangePasswordResults.NewPasswordSameAsOld) {
-            this.setErrors([translate('user.new_password_same_as_old')]);
+            setError(translate('user.new_password_same_as_old'));
           }
         });
     }
   };
 
-  render() {
-    const { className } = this.props;
-    const { success, errors } = this.state;
-
-    return (
-      <form className={className} onSubmit={this.handleChangePassword}>
-        {success && <Alert variant="success">{translate('my_profile.password.changed')}</Alert>}
+  return (
+    <form className={className} onSubmit={handleChangePassword}>
+      {success && (
+        <div className="sw-pb-4">
+          <FlagMessage variant="success">{translate('my_profile.password.changed')}</FlagMessage>
+        </div>
+      )}
 
-        {errors &&
-          errors.map((e) => (
-            <Alert key={e} variant="error">
-              {e}
-            </Alert>
-          ))}
+      {error !== undefined && (
+        <div className="sw-pb-4">
+          <FlagMessage variant="error">{error}</FlagMessage>
+        </div>
+      )}
 
-        <MandatoryFieldsExplanation className="form-field" />
+      <MandatoryFieldsExplanation className="sw-block sw-clear-both sw-pb-4" />
 
-        <div className="form-field">
-          <label htmlFor="old_password">
-            {translate('my_profile.password.old')}
-            <MandatoryFieldMarker />
-          </label>
-          <input
+      <div className="sw-pb-4">
+        <FormField htmlFor="old_password" label={translate('my_profile.password.old')} required>
+          <InputField
             autoComplete="off"
             id="old_password"
             name="old_password"
-            ref={(elem) => (this.oldPassword = elem)}
+            onChange={(e: React.ChangeEvent<HTMLInputElement>) => setOldPassword(e.target.value)}
             required
             type="password"
+            value={oldPassword}
           />
-        </div>
-        <div className="form-field">
-          <label htmlFor="password">
-            {translate('my_profile.password.new')}
-            <MandatoryFieldMarker />
-          </label>
-          <input
+        </FormField>
+      </div>
+
+      <div className="sw-pb-4">
+        <FormField htmlFor="password" label={translate('my_profile.password.new')} required>
+          <InputField
             autoComplete="off"
             id="password"
             name="password"
-            ref={(elem) => (this.password = elem)}
+            onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
             required
             type="password"
+            value={password}
           />
-        </div>
-        <div className="form-field">
-          <label htmlFor="password_confirmation">
-            {translate('my_profile.password.confirm')}
-            <MandatoryFieldMarker />
-          </label>
-          <input
+        </FormField>
+      </div>
+
+      <div className="sw-pb-4">
+        <FormField
+          htmlFor="password_confirmation"
+          label={translate('my_profile.password.confirm')}
+          required
+        >
+          <InputField
             autoComplete="off"
             id="password_confirmation"
             name="password_confirmation"
-            ref={(elem) => (this.passwordConfirmation = elem)}
+            onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
+              setPasswordConfirmation(e.target.value)
+            }
             required
             type="password"
+            value={passwordConfirmation}
           />
-        </div>
-        <div className="form-field">
-          <SubmitButton id="change-password">{translate('update_verb')}</SubmitButton>
-        </div>
-      </form>
-    );
-  }
+        </FormField>
+      </div>
+
+      <div className="sw-py-3">
+        <ButtonPrimary id="change-password" type="submit">
+          {translate('update_verb')}
+        </ButtonPrimary>
+      </div>
+    </form>
+  );
 }