Skip to content

Commit

Permalink
feat: [M3-8719] - Add a 'Mask Sensitive Data' setting to Cloud Manager (
Browse files Browse the repository at this point in the history
#11143)

* Add boolean for redacted data to user preferences

* Add RedactableText component

* Apply RedactableText to Linode landing IP Addresses

* Try a styling fix

* Fix logic error and rename variables to be less confusing

* Redact text on Account Login History tab and Profile Login & Auth tab

* Update copy

* Create and test util for masked string

* Mask text in IPAddress component

* Replace instances of RedactableText with MaskableText

* Mask billing contact info

* Mask user pages

* Hide masking toggle icon for IPAddresses when setting off

* Mask top menu

* Clean up props

* Revert top menu

* Fix copy icon not copying plaintext value

* Rename preference key for consistency

* Clean up - typo, linting, comments

* Unit test IPAddress component and fix issues

* Rename MaskableTextTooltip to VisibilityTooltip

* Add test for MaskableText

* Add stories

* Organize tooltip story with rest of tooltips

* Mask fields in AccessTable

* Mask IP addresses in Linode Details Network tab

* Mask credit card and third party payment details

* Fix doubly masked third party payment

* Added changeset: Mask Sensitive Data preference to Profile Settings

* Clean up of a variable name for consistency in naming

* Fix Tooltip import

* Update packages/manager/src/utilities/createMaskedText.ts

Co-authored-by: Hana Xu <[email protected]>

* Address feedback: clean up unnecessary prop; update test

* Address feedback: add constant for unmaskedText

* Address feedback: Replace displayText with masked prop in CopyTooltip

* Add masked visibility toggle to CopyTooltip

* Move VisibilityTooltip to ui package

* Address feedback: misalignment of AccessTable row components

* Fix circular dependency and update IPAddress unit test

* Update unit test coverage

* Address feedback: tooltip interactivity, placement, hover color

* Mask Kube Details page

* Mask Firewall rules and lint

* Mask Service Transfers

* Mask OBJ hostname and access keys

* Mask Trusted Devices

* Address feedback: update children prop, remove fragments

* Remove problematic Storybook story

* Address feedback: update MaskableText and util to use consistent lengths

* Set the length of masked data fields

* Fix console errors and test failures

* Fix the unit test that escaped me

* Add documentation of feature

* Fix a markdown issue in the PR template

---------

Co-authored-by: Hana Xu <[email protected]>
  • Loading branch information
mjac0bs and hana-akamai authored Nov 1, 2024
1 parent 2e6defb commit 42f6cc2
Show file tree
Hide file tree
Showing 41 changed files with 865 additions and 149 deletions.
3 changes: 2 additions & 1 deletion docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ Feel free to open an issue to report a bug or request a feature.
**Example:** `feat: [M3-1234] - Allow user to view their login history`

6. Open a pull request against `develop` and make sure the title follows the same format as the commit message.
7. If needed, create a changeset to populate our changelog.
7. Keep in mind that our repository is public and open source! Before adding screenshots to your PR, we recommend you enable the **Mask Sensitive Data** setting in Cloud Manager [Profile Settings](https://cloud.linode.com/profile/settings).
8. If needed, create a changeset to populate our changelog.
- If you don't have the Github CLI installed or need to update it (you need GH CLI 2.21.0 or greater),
- install it via `brew`: https://github.com/cli/cli#installation or upgrade with `brew upgrade gh`
- Once installed, run `gh repo set-default` and pick `linode/manager` (only > 2.21.0)
Expand Down
4 changes: 3 additions & 1 deletion docs/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ List any change relevant to the reviewer.
Please specify a release date to guarantee timely review of this PR. If exact date is not known, please approximate and update it as needed.

## Preview 📷
**Include a screenshot or screen recording of the change**
**Include a screenshot or screen recording of the change.**

:lock: Use the [Mask Sensitive Data](https://cloud.linode.com/profile/settings) setting for security.

:bulb: Use `<video src="" />` tag when including recordings in table.

Expand Down
4 changes: 4 additions & 0 deletions docs/development-guide/02-component-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ When building a large component, it is recommended to break it down and avoid wr
Components should, in most cases, come with their own unit test, although they can be skipped if an e2e suite is covering the functionality.
Utilities should almost always feature a unit test.

#### Security

Consider whether the component is displaying data that may be sensitive, such as IP addresses or personal contact information. If so, make use of the `MaskableText` component or `masked` property of the `CopyTooltip` to hide this data for users who choose to 'Mask Sensitive Data' via Profile Settings.

#### Styles

- With the transition to MUI v5, the [`styled`](https://mui.com/system/styled/) API, along with the [`sx` prop](https://mui.com/system/getting-started/the-sx-prop/), is the preferred way to specify component-specific styles.
Expand Down
5 changes: 5 additions & 0 deletions packages/manager/.changeset/pr-11143-added-1729792743385.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Added
---

Mask Sensitive Data preference to Profile Settings ([#11143](https://github.com/linode/manager/pull/11143))
88 changes: 88 additions & 0 deletions packages/manager/src/components/CopyTooltip/CopyTooltip.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { act, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as React from 'react';

import { renderWithTheme } from 'src/utilities/testHelpers';

import { CopyTooltip } from './CopyTooltip';

import type { CopyTooltipProps } from './CopyTooltip';

const mockText = 'Hello world';

const defaultProps: CopyTooltipProps = {
text: mockText,
};

describe('CopyTooltip', () => {
it('should render the copy icon button and tooltip', async () => {
const { getByLabelText, getByRole } = renderWithTheme(
<CopyTooltip {...defaultProps} />
);

const copyIconButton = getByLabelText(`Copy ${mockText} to clipboard`);

await act(() => userEvent.hover(copyIconButton));

await waitFor(() => {
const copyTooltip = getByRole('tooltip');
expect(copyTooltip).toBeInTheDocument();
expect(copyTooltip).toHaveTextContent('Copy');
});

await userEvent.click(copyIconButton);

await waitFor(() => {
const copiedTooltip = getByRole('tooltip', {});
expect(copiedTooltip).toBeInTheDocument();
expect(copiedTooltip).toHaveTextContent('Copied!');
});
});

it('should render text with the copyableText property', async () => {
const { getByLabelText, getByText } = renderWithTheme(
<CopyTooltip {...defaultProps} copyableText />
);

expect(getByLabelText(`Copy ${mockText} to clipboard`)).toBeInTheDocument();
expect(getByText(mockText)).toBeVisible();
});

it('should disable the tooltip text with the disable property', async () => {
const { getByLabelText } = renderWithTheme(
<CopyTooltip {...defaultProps} disabled />
);

const copyIconButton = getByLabelText(`Copy ${mockText} to clipboard`);
expect(copyIconButton).toBeDisabled();
});

it('should mask and toggle visibility of tooltip text with the masked property', async () => {
const {
getByLabelText,
getByTestId,
getByText,
queryByText,
} = renderWithTheme(
<CopyTooltip
{...defaultProps}
copyableText
masked
maskedTextLength="plaintext"
/>
);

const copyIconButton = getByLabelText(`Copy ${mockText} to clipboard`);
const visibilityToggle = getByTestId('VisibilityIcon');

// Text should be masked
expect(copyIconButton).toBeInTheDocument();
expect(getByText('•••••••••••')).toBeVisible();
expect(queryByText(mockText)).toBeNull();

await act(() => userEvent.click(visibilityToggle));

// Text should be unmasked
expect(getByText('Hello world')).toBeVisible();
});
});
59 changes: 47 additions & 12 deletions packages/manager/src/components/CopyTooltip/CopyTooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Tooltip } from '@linode/ui';
import { Tooltip, VisibilityTooltip } from '@linode/ui';
import { styled } from '@mui/material/styles';
import copy from 'copy-to-clipboard';
import * as React from 'react';

import FileCopy from 'src/assets/icons/copy.svg';
import { createMaskedText } from 'src/utilities/createMaskedText';
import { omittedProps } from 'src/utilities/omittedProps';

import type { MaskableTextLength } from '../MaskableText/MaskableText';
import type { TooltipProps } from '@linode/ui';

export interface CopyTooltipProps {
Expand All @@ -23,9 +25,19 @@ export interface CopyTooltipProps {
* @default false
*/
disabled?: boolean;
/**
* If true, the text will be masked with dots when displayed. It will still be copyable.
* @default false
*/
masked?: boolean;
/**
* Optionally specifies the length of the masked text to depending on data type (e.g. 'ipv4', 'ipv6', 'plaintext'); if not provided, will use a default length.
*/
maskedTextLength?: MaskableTextLength;
/**
* Callback to be executed when the icon is clicked.
*/

onClickCallback?: () => void;
/**
* The placement of the tooltip.
Expand All @@ -44,16 +56,24 @@ export interface CopyTooltipProps {
*/

export const CopyTooltip = (props: CopyTooltipProps) => {
const [copied, setCopied] = React.useState<boolean>(false);
const {
className,
copyableText,
disabled,
masked,
maskedTextLength,
onClickCallback,
placement,
text,
} = props;

const [copied, setCopied] = React.useState<boolean>(false);
const [isTextMasked, setIsTextMasked] = React.useState(masked);

const displayText = isTextMasked
? createMaskedText(text, maskedTextLength)
: text;

const handleIconClick = () => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
Expand All @@ -73,7 +93,7 @@ export const CopyTooltip = (props: CopyTooltipProps) => {
type="button"
{...props}
>
{copyableText ? text : <FileCopy />}
{copyableText ? displayText : <FileCopy />}
</StyledIconButton>
);

Expand All @@ -82,20 +102,35 @@ export const CopyTooltip = (props: CopyTooltipProps) => {
}

return (
<Tooltip
className="copy-tooltip"
data-qa-copied
placement={placement ?? 'top'}
title={copied ? 'Copied!' : 'Copy'}
>
{CopyButton}
</Tooltip>
<>
<Tooltip
className="copy-tooltip"
data-qa-copied
disableInteractive
placement={placement ?? 'top'}
title={copied ? 'Copied!' : 'Copy'}
>
{CopyButton}
</Tooltip>
{masked && (
<VisibilityTooltip
handleClick={() => setIsTextMasked(!isTextMasked)}
isVisible={!isTextMasked}
/>
)}
</>
);
};

export const StyledIconButton = styled('button', {
label: 'StyledIconButton',
shouldForwardProp: omittedProps(['copyableText', 'text', 'onClickCallback']),
shouldForwardProp: omittedProps([
'copyableText',
'text',
'onClickCallback',
'masked',
'maskedTextLength',
]),
})<Omit<CopyTooltipProps, 'text'>>(({ theme, ...props }) => ({
'& svg': {
color: theme.color.grey1,
Expand Down
107 changes: 107 additions & 0 deletions packages/manager/src/components/MaskableText/MaskableText.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import userEvent from '@testing-library/user-event';
import React from 'react';

import { renderWithTheme } from 'src/utilities/testHelpers';

import { MaskableText } from './MaskableText';

import type { MaskableTextProps } from './MaskableText';
import type { ManagerPreferences } from 'src/types/ManagerPreferences';

describe('MaskableText', () => {
const maskedText = '•••••••••••';
const plainText = 'my-username';

const defaultProps: MaskableTextProps = {
isToggleable: false,
length: 'plaintext',
text: plainText,
};

const preferences: ManagerPreferences = {
maskSensitiveData: true,
};

const queryMocks = vi.hoisted(() => ({
usePreferences: vi.fn().mockReturnValue({}),
}));

vi.mock('src/queries/profile/preferences', async () => {
const actual = await vi.importActual('src/queries/profile/preferences');
return {
...actual,
usePreferences: queryMocks.usePreferences,
};
});

queryMocks.usePreferences.mockReturnValue({
data: preferences,
});

it('should render masked text if the maskSensitiveData preference is enabled', () => {
queryMocks.usePreferences.mockReturnValue({
data: preferences,
});

const { getByText, queryByText } = renderWithTheme(
<MaskableText {...defaultProps} />
);

// Original text should be masked
expect(getByText(maskedText)).toBeVisible();
expect(queryByText(plainText)).not.toBeInTheDocument();
});

it('should not render masked text if the maskSensitiveData preference is disabled', () => {
queryMocks.usePreferences.mockReturnValue({
data: {
maskSensitiveData: false,
},
});

const { getByText, queryByText } = renderWithTheme(
<MaskableText {...defaultProps} />
);

// Original text should be visible
expect(getByText(plainText)).toBeVisible();
expect(queryByText(maskedText)).not.toBeInTheDocument();
});

it("should render MaskableText's children if the maskSensitiveData preference is disabled and children are provided", () => {
queryMocks.usePreferences.mockReturnValue({
data: {
maskSensitiveData: false,
},
});

const plainTextElement = <div>{plainText}</div>;
const { getByText, queryByText } = renderWithTheme(
<MaskableText {...defaultProps}>{plainTextElement}</MaskableText>
);

// Original text should be visible
expect(getByText(plainText)).toBeInTheDocument();
expect(queryByText(maskedText)).not.toBeInTheDocument();
});

it('should render a toggleable VisibilityIcon tooltip if isToggleable is provided', async () => {
queryMocks.usePreferences.mockReturnValue({
data: preferences,
});

const { getByTestId, getByText } = renderWithTheme(
<MaskableText {...defaultProps} isToggleable />
);

const visibilityToggle = getByTestId('VisibilityIcon');

// Original text should be masked
expect(getByText(maskedText)).toBeVisible();

await userEvent.click(visibilityToggle);

// Original text should be unmasked
expect(getByText(plainText)).toBeVisible();
});
});
Loading

0 comments on commit 42f6cc2

Please sign in to comment.