diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md
index cbf69499d81..c64ee516fe9 100644
--- a/docs/CONTRIBUTING.md
+++ b/docs/CONTRIBUTING.md
@@ -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)
diff --git a/docs/PULL_REQUEST_TEMPLATE.md b/docs/PULL_REQUEST_TEMPLATE.md
index 15e72c8495e..01333bdab73 100644
--- a/docs/PULL_REQUEST_TEMPLATE.md
+++ b/docs/PULL_REQUEST_TEMPLATE.md
@@ -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 `` tag when including recordings in table.
diff --git a/docs/development-guide/02-component-structure.md b/docs/development-guide/02-component-structure.md
index 004e4ba0923..3cd72cdf54e 100644
--- a/docs/development-guide/02-component-structure.md
+++ b/docs/development-guide/02-component-structure.md
@@ -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.
diff --git a/packages/manager/.changeset/pr-11143-added-1729792743385.md b/packages/manager/.changeset/pr-11143-added-1729792743385.md
new file mode 100644
index 00000000000..1732ea131b2
--- /dev/null
+++ b/packages/manager/.changeset/pr-11143-added-1729792743385.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Added
+---
+
+Mask Sensitive Data preference to Profile Settings ([#11143](https://github.com/linode/manager/pull/11143))
diff --git a/packages/manager/src/components/CopyTooltip/CopyTooltip.test.tsx b/packages/manager/src/components/CopyTooltip/CopyTooltip.test.tsx
new file mode 100644
index 00000000000..e18c0dc546a
--- /dev/null
+++ b/packages/manager/src/components/CopyTooltip/CopyTooltip.test.tsx
@@ -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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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();
+ });
+});
diff --git a/packages/manager/src/components/CopyTooltip/CopyTooltip.tsx b/packages/manager/src/components/CopyTooltip/CopyTooltip.tsx
index 1d263a6fb49..d4cdca2642b 100644
--- a/packages/manager/src/components/CopyTooltip/CopyTooltip.tsx
+++ b/packages/manager/src/components/CopyTooltip/CopyTooltip.tsx
@@ -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 {
@@ -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.
@@ -44,16 +56,24 @@ export interface CopyTooltipProps {
*/
export const CopyTooltip = (props: CopyTooltipProps) => {
- const [copied, setCopied] = React.useState(false);
const {
className,
copyableText,
disabled,
+ masked,
+ maskedTextLength,
onClickCallback,
placement,
text,
} = props;
+ const [copied, setCopied] = React.useState(false);
+ const [isTextMasked, setIsTextMasked] = React.useState(masked);
+
+ const displayText = isTextMasked
+ ? createMaskedText(text, maskedTextLength)
+ : text;
+
const handleIconClick = () => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
@@ -73,7 +93,7 @@ export const CopyTooltip = (props: CopyTooltipProps) => {
type="button"
{...props}
>
- {copyableText ? text : }
+ {copyableText ? displayText : }
);
@@ -82,20 +102,35 @@ export const CopyTooltip = (props: CopyTooltipProps) => {
}
return (
-
- {CopyButton}
-
+ <>
+
+ {CopyButton}
+
+ {masked && (
+ setIsTextMasked(!isTextMasked)}
+ isVisible={!isTextMasked}
+ />
+ )}
+ >
);
};
export const StyledIconButton = styled('button', {
label: 'StyledIconButton',
- shouldForwardProp: omittedProps(['copyableText', 'text', 'onClickCallback']),
+ shouldForwardProp: omittedProps([
+ 'copyableText',
+ 'text',
+ 'onClickCallback',
+ 'masked',
+ 'maskedTextLength',
+ ]),
})>(({ theme, ...props }) => ({
'& svg': {
color: theme.color.grey1,
diff --git a/packages/manager/src/components/MaskableText/MaskableText.test.tsx b/packages/manager/src/components/MaskableText/MaskableText.test.tsx
new file mode 100644
index 00000000000..8e4061db9a9
--- /dev/null
+++ b/packages/manager/src/components/MaskableText/MaskableText.test.tsx
@@ -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(
+
+ );
+
+ // 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(
+
+ );
+
+ // 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 =
{plainText}
;
+ const { getByText, queryByText } = renderWithTheme(
+ {plainTextElement}
+ );
+
+ // 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(
+
+ );
+
+ 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();
+ });
+});
diff --git a/packages/manager/src/components/MaskableText/MaskableText.tsx b/packages/manager/src/components/MaskableText/MaskableText.tsx
new file mode 100644
index 00000000000..ac0b8ccd68e
--- /dev/null
+++ b/packages/manager/src/components/MaskableText/MaskableText.tsx
@@ -0,0 +1,73 @@
+import { VisibilityTooltip } from '@linode/ui';
+import { Typography } from '@mui/material';
+import * as React from 'react';
+
+import { usePreferences } from 'src/queries/profile/preferences';
+import { createMaskedText } from 'src/utilities/createMaskedText';
+
+import { Stack } from '../Stack';
+
+export type MaskableTextLength = 'ipv4' | 'ipv6' | 'plaintext';
+
+export interface MaskableTextProps {
+ /**
+ * (Optional) original JSX element to render if the text is not masked.
+ */
+ children?: JSX.Element | JSX.Element[];
+ /**
+ * If true, displays a VisibilityTooltip icon to toggle the masked and unmasked text.
+ */
+ isToggleable?: 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.
+ */
+ length?: MaskableTextLength;
+ /**
+ * The original, maskable text; if the text is not masked, render this text or the styled text via children.
+ */
+ text: string | undefined;
+}
+
+export const MaskableText = (props: MaskableTextProps) => {
+ const { children, isToggleable = false, text, length } = props;
+
+ const { data: preferences } = usePreferences();
+ const maskedPreferenceSetting = preferences?.maskSensitiveData;
+
+ const [isMasked, setIsMasked] = React.useState(maskedPreferenceSetting);
+
+ const unmaskedText = children ? children : {text};
+
+ // Return early based on the preference setting and the original text.
+
+ if (!text) {
+ return;
+ }
+
+ if (!maskedPreferenceSetting) {
+ return unmaskedText;
+ }
+
+ return (
+
+ {isMasked ? (
+
+ {createMaskedText(text, length)}
+
+ ) : (
+ unmaskedText
+ )}
+ {isToggleable && (
+ setIsMasked(!isMasked)}
+ isVisible={!isMasked}
+ />
+ )}
+
+ );
+};
diff --git a/packages/manager/src/components/PaymentMethodRow/ThirdPartyPayment.tsx b/packages/manager/src/components/PaymentMethodRow/ThirdPartyPayment.tsx
index ba8d1f87c4f..2b974fab87b 100644
--- a/packages/manager/src/components/PaymentMethodRow/ThirdPartyPayment.tsx
+++ b/packages/manager/src/components/PaymentMethodRow/ThirdPartyPayment.tsx
@@ -9,6 +9,8 @@ import PayPalIcon from 'src/assets/icons/payment/payPal.svg';
import { Typography } from 'src/components/Typography';
import CreditCard from 'src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/CreditCard';
+import { MaskableText } from '../MaskableText/MaskableText';
+
import type {
ThirdPartyPayment as _ThirdPartyPayment,
PaymentMethod,
@@ -51,16 +53,20 @@ interface Props {
paymentMethod: PaymentMethod;
}
-export const renderThirdPartyPaymentBody = (paymentMethod: PaymentMethod) => {
+export const ThirdPartyPaymentBody = (props: Props) => {
+ const { paymentMethod } = props;
+
// eslint-disable-next-line sonarjs/no-small-switch
switch (paymentMethod.type) {
case 'paypal':
return (
-
-
- {paymentMethod.data.email}
-
-
+
+
+
+ {paymentMethod.data.email}
+
+
+
);
default:
return ;
@@ -94,7 +100,7 @@ export const ThirdPartyPayment = (props: Props) => {
}
)}
- {renderThirdPartyPaymentBody(paymentMethod)}
+
>
);
diff --git a/packages/manager/src/factories/preferences.ts b/packages/manager/src/factories/preferences.ts
index 3072b842b4b..6c2bed4abc4 100644
--- a/packages/manager/src/factories/preferences.ts
+++ b/packages/manager/src/factories/preferences.ts
@@ -1,6 +1,6 @@
import Factory from 'src/factories/factoryProxy';
-import { ManagerPreferences } from 'src/types/ManagerPreferences';
+import type { ManagerPreferences } from 'src/types/ManagerPreferences';
export const preferencesFactory = Factory.Sync.makeFactory({
backups_cta_dismissed: true,
@@ -21,6 +21,7 @@ export const preferencesFactory = Factory.Sync.makeFactory({
linodes_view_style: 'grid',
longviewTimeRange: '',
main_content_banner_dismissal: { t: true },
+ maskSensitiveData: true,
nodebalancers_group_by_tag: true,
sortKeys: {},
theme: 'light',
diff --git a/packages/manager/src/features/Account/AccountLoginsTableRow.tsx b/packages/manager/src/features/Account/AccountLoginsTableRow.tsx
index 1fa71109e86..c49dcec9e9d 100644
--- a/packages/manager/src/features/Account/AccountLoginsTableRow.tsx
+++ b/packages/manager/src/features/Account/AccountLoginsTableRow.tsx
@@ -1,18 +1,21 @@
-import {
- AccountLogin,
- AccountLoginStatus,
-} from '@linode/api-v4/lib/account/types';
import * as React from 'react';
import { Hidden } from 'src/components/Hidden';
import { Link } from 'src/components/Link';
-import { Status, StatusIcon } from 'src/components/StatusIcon/StatusIcon';
+import { MaskableText } from 'src/components/MaskableText/MaskableText';
+import { StatusIcon } from 'src/components/StatusIcon/StatusIcon';
import { TableCell } from 'src/components/TableCell';
import { TableRow } from 'src/components/TableRow';
import { useProfile } from 'src/queries/profile/profile';
import { capitalize } from 'src/utilities/capitalize';
import { formatDate } from 'src/utilities/formatDate';
+import type {
+ AccountLogin,
+ AccountLoginStatus,
+} from '@linode/api-v4/lib/account/types';
+import type { Status } from 'src/components/StatusIcon/StatusIcon';
+
const accessIconMap: Record = {
failed: 'other',
successful: 'active',
@@ -30,10 +33,14 @@ const AccountLoginsTableRow = (props: AccountLogin) => {
})}
- {username}
+
+ {username}
+
- {ip}
+
+
+
diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/CreditCard.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/CreditCard.tsx
index bef066d154c..18649d7bd82 100644
--- a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/CreditCard.tsx
+++ b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/CreditCard.tsx
@@ -8,6 +8,7 @@ import DiscoverIcon from 'src/assets/icons/payment/discover.svg';
import JCBIcon from 'src/assets/icons/payment/jcb.svg';
import MastercardIcon from 'src/assets/icons/payment/mastercard.svg';
import VisaIcon from 'src/assets/icons/payment/visa.svg';
+import { MaskableText } from 'src/components/MaskableText/MaskableText';
import { Typography } from 'src/components/Typography';
import { formatExpiry, isCreditCardExpired } from 'src/utilities/creditCard';
@@ -76,6 +77,7 @@ export const CreditCard = (props: Props) => {
const { classes } = useStyles();
const Icon = type ? getIcon(type) : GenericCardIcon;
+ const displayText = `${type || 'Card ending in'} ****${lastFour}`;
return (
<>
@@ -87,18 +89,20 @@ export const CreditCard = (props: Props) => {
) : null}
-
- {`${type || 'Card ending in'} ****${lastFour}`}
-
-
- {expiry && isCreditCardExpired(expiry) ? (
- {`Expired ${formatExpiry(
- expiry
- )}`}
- ) : expiry ? (
- {`Expires ${formatExpiry(expiry)}`}
- ) : null}
-
+
+
+ {displayText}
+
+
+ {expiry && isCreditCardExpired(expiry) ? (
+ {`Expired ${formatExpiry(
+ expiry
+ )}`}
+ ) : expiry ? (
+ {`Expires ${formatExpiry(expiry)}`}
+ ) : null}
+
+
>
);
diff --git a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx
index 998c2cfb521..674bb0f31c0 100644
--- a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx
+++ b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx
@@ -5,6 +5,7 @@ import { allCountries } from 'country-region-data';
import * as React from 'react';
import { useHistory, useRouteMatch } from 'react-router-dom';
+import { MaskableText } from 'src/components/MaskableText/MaskableText';
import { TooltipIcon } from 'src/components/TooltipIcon';
import { Typography } from 'src/components/Typography';
import { getRestrictedResourceText } from 'src/features/Account/utils';
@@ -189,63 +190,90 @@ const ContactInformation = (props: Props) => {
country) && (
{(firstName || lastName) && (
-
- {firstName} {lastName}
-
+
+ {firstName} {lastName}
+
+
)}
{company && (
-
- {company}
-
+
+ <>
+ {' '}
+
+ {company}
+
+ >
+
)}
{(address1 || address2 || city || state || zip || country) && (
- <>
-
- {address1}
-
- {address2}
- >
+
+ <>
+
+ {address1}
+
+ {address2}
+ >
+
)}
-
- {city}
- {city && state && ','} {state} {zip}
-
- {countryName}
+
+
+ {city}
+ {city && state && ','} {state} {zip}
+
+
+
+ {countryName}
+
)}
-
- {email}
-
+
+
+ {email}
+
+
{phone && (
- {phone}
+
+
+ {phone}
+
+
)}
{taxId && (
-
-
- Tax ID {taxId}
-
- {taxIdIsVerifyingNotification && (
- }
- status="other"
- text={taxIdIsVerifyingNotification.label}
- />
- )}
-
+
+
+
+ Tax ID {taxId}
+
+ {taxIdIsVerifyingNotification && (
+ }
+ status="other"
+ text={taxIdIsVerifyingNotification.label}
+ />
+ )}
+
+
)}
diff --git a/packages/manager/src/features/EntityTransfers/RenderTransferRow.tsx b/packages/manager/src/features/EntityTransfers/RenderTransferRow.tsx
index f8c98f60d8f..902259d38dd 100644
--- a/packages/manager/src/features/EntityTransfers/RenderTransferRow.tsx
+++ b/packages/manager/src/features/EntityTransfers/RenderTransferRow.tsx
@@ -1,9 +1,9 @@
-import { TransferEntities } from '@linode/api-v4/lib/entity-transfers';
import * as React from 'react';
import { StyledLinkButton } from 'src/components/Button/StyledLinkButton';
import { DateTimeDisplay } from 'src/components/DateTimeDisplay';
import { Hidden } from 'src/components/Hidden';
+import { MaskableText } from 'src/components/MaskableText/MaskableText';
import { TableCell } from 'src/components/TableCell';
import { capitalize } from 'src/utilities/capitalize';
import { pluralize } from 'src/utilities/pluralize';
@@ -19,6 +19,8 @@ import {
} from './RenderTransferRow.styles';
import { TransfersPendingActionMenu } from './TransfersPendingActionMenu';
+import type { TransferEntities } from '@linode/api-v4/lib/entity-transfers';
+
interface Props {
created: string;
entities: TransferEntities;
@@ -59,9 +61,11 @@ export const RenderTransferRow = React.memo((props: Props) => {
- handleTokenClick(token, entities)}>
- {token}
-
+
+ handleTokenClick(token, entities)}>
+ {token}
+
+
diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx
index a6d37b59a3d..b9f8229d100 100644
--- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx
@@ -8,6 +8,7 @@ import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd';
import Undo from 'src/assets/icons/undo.svg';
import { Autocomplete } from 'src/components/Autocomplete/Autocomplete';
import { Hidden } from 'src/components/Hidden';
+import { MaskableText } from 'src/components/MaskableText/MaskableText';
import { Typography } from 'src/components/Typography';
import {
generateAddressesLabel,
@@ -330,7 +331,8 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => {
aria-label={`Addresses: ${addresses}`}
sx={{ ...sxItemSpacing, overflowWrap: 'break-word', width: '15%' }}
>
- {addresses}
+
+ {
if (endpoint) {
- return endpoint;
+ return ;
}
if (endpointLoading) {
- return 'Loading...';
+ return Loading...;
}
if (endpointError) {
- return endpointError;
+ return {endpointError};
}
- return 'Your endpoint will be displayed here once it is available.';
+ return (
+
+ Your endpoint will be displayed here once it is available.
+
+ );
};
export const KubeConfigDisplay = (props: Props) => {
@@ -135,15 +140,13 @@ export const KubeConfigDisplay = (props: Props) => {
Kubernetes API Endpoint:
-
- {renderEndpoint(
- getEndpointToDisplay(
- endpoints?.map((endpoint) => endpoint.endpoint) ?? []
- ),
- endpointsLoading,
- endpointsError?.[0].reason
- )}
-
+ {renderEndpoint(
+ getEndpointToDisplay(
+ endpoints?.map((endpoint) => endpoint.endpoint) ?? []
+ ),
+ endpointsLoading,
+ endpointsError?.[0].reason
+ )}
diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeRow.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeRow.tsx
index 27a278a2ec1..2aba5674d8a 100644
--- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeRow.tsx
+++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeRow.tsx
@@ -8,6 +8,7 @@ import { TableCell } from 'src/components/TableCell';
import { Typography } from 'src/components/Typography';
import { transitionText } from 'src/features/Linodes/transitions';
import { useInProgressEvents } from 'src/queries/events/events';
+import { usePreferences } from 'src/queries/profile/preferences';
import NodeActionMenu from './NodeActionMenu';
import { StyledCopyTooltip, StyledTableRow } from './NodeTable.styles';
@@ -43,6 +44,7 @@ export const NodeRow = React.memo((props: NodeRowProps) => {
} = props;
const { data: events } = useInProgressEvents();
+ const { data: preferences } = usePreferences();
const recentEvent = events?.find(
(event) =>
@@ -112,7 +114,12 @@ export const NodeRow = React.memo((props: NodeRowProps) => {
) : displayIP.length > 0 ? (
<>
-
+
>
) : null}
diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.styles.ts b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.styles.ts
index f272e64c72c..97675764fff 100644
--- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.styles.ts
+++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.styles.ts
@@ -11,7 +11,6 @@ export const StyledTableRow = styled(TableRow, {
})(({ theme }) => ({
'& svg': {
height: `12px`,
- opacity: 0,
width: `12px`,
},
'&:hover': {
diff --git a/packages/manager/src/features/Linodes/AccessTable.tsx b/packages/manager/src/features/Linodes/AccessTable.tsx
index 4c886256df6..a6b747c41e0 100644
--- a/packages/manager/src/features/Linodes/AccessTable.tsx
+++ b/packages/manager/src/features/Linodes/AccessTable.tsx
@@ -17,9 +17,12 @@ import {
} from './LinodeEntityDetail.styles';
import type { SxProps, Theme } from '@mui/material/styles';
+import type { MaskableTextLength } from 'src/components/MaskableText/MaskableText';
interface AccessTableRow {
heading?: string;
+ isMasked?: boolean;
+ maskedTextLength?: MaskableTextLength;
text: null | string;
}
@@ -61,6 +64,8 @@ export const AccessTable = React.memo((props: AccessTableProps) => {
diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetail.styles.ts b/packages/manager/src/features/Linodes/LinodeEntityDetail.styles.ts
index e165f260b81..3205fd5973b 100644
--- a/packages/manager/src/features/Linodes/LinodeEntityDetail.styles.ts
+++ b/packages/manager/src/features/Linodes/LinodeEntityDetail.styles.ts
@@ -189,6 +189,7 @@ export const StyledGradientDiv = styled('div', { label: 'StyledGradientDiv' })(
right: 0,
width: 30,
},
+ display: 'flex',
overflowX: 'auto',
overflowY: 'hidden', // For Edge
paddingRight: 15,
diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetailBody.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetailBody.tsx
index 70ab5290a57..ac5a5480935 100644
--- a/packages/manager/src/features/Linodes/LinodeEntityDetailBody.tsx
+++ b/packages/manager/src/features/Linodes/LinodeEntityDetailBody.tsx
@@ -13,6 +13,7 @@ import { useIsDiskEncryptionFeatureEnabled } from 'src/components/Encryption/uti
import { Link } from 'src/components/Link';
import { Typography } from 'src/components/Typography';
import { AccessTable } from 'src/features/Linodes/AccessTable';
+import { usePreferences } from 'src/queries/profile/preferences';
import { useProfile } from 'src/queries/profile/profile';
import { pluralize } from 'src/utilities/pluralize';
@@ -90,6 +91,7 @@ export const LinodeEntityDetailBody = React.memo((props: BodyProps) => {
} = props;
const { data: profile } = useProfile();
+ const { data: preferences } = usePreferences();
const username = profile?.username ?? 'none';
const theme = useTheme();
@@ -188,17 +190,35 @@ export const LinodeEntityDetailBody = React.memo((props: BodyProps) => {
) : undefined
}
+ rows={[
+ {
+ isMasked: preferences?.maskSensitiveData,
+ maskedTextLength: 'ipv4',
+ text: firstAddress,
+ },
+ {
+ isMasked: preferences?.maskSensitiveData,
+ maskedTextLength: 'ipv6',
+ text: secondAddress,
+ },
+ ]}
gridSize={{ lg: 5, xs: 12 }}
isVPCOnlyLinode={isVPCOnlyLinode}
- rows={[{ text: firstAddress }, { text: secondAddress }]}
sx={{ padding: 0 }}
title={`Public IP Address${numIPAddresses > 1 ? 'es' : ''}`}
/>
{
} = props;
const { data: ips } = useLinodeIPsQuery(linodeId);
+ const { data: preferences } = usePreferences();
const isOnlyPublicIP =
ips?.ipv4.public.length === 1 && type === 'IPv4 – Public';
@@ -67,7 +69,13 @@ export const LinodeIPAddressRow = (props: LinodeIPAddressRowProps) => {
parentColumn="Address"
sx={{ whiteSpace: 'nowrap' }}
>
-
+
{!isVPCOnlyLinode && }
{
isIpHovered?: boolean;
@@ -44,6 +44,7 @@ export const StyledCopyTooltip = styled(CopyTooltip, {
'isHovered',
'isIpHovered',
'showTooltipOnIpHover',
+ 'displayText',
]),
})(
({ isHovered, isIpHovered, showTooltipOnIpHover, theme }) => ({
diff --git a/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.test.tsx b/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.test.tsx
index 419f7583082..3431e9ba257 100644
--- a/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.test.tsx
+++ b/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.test.tsx
@@ -1,9 +1,12 @@
+import userEvent from '@testing-library/user-event';
import * as React from 'react';
import { renderWithTheme } from 'src/utilities/testHelpers';
import { IPAddress, sortIPAddress } from './IPAddress';
+import type { ManagerPreferences } from 'src/types/ManagerPreferences';
+
const publicIP = '8.8.8.8';
const publicIP2 = '45.45.45.45';
const privateIP = '192.168.220.103';
@@ -96,3 +99,87 @@ describe('IP address sorting', () => {
).toEqual([publicIP, publicIP2, privateIP, privateIP2]);
});
});
+
+describe('IPAddress masked', () => {
+ 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,
+ };
+ });
+
+ it('should mask all shown IP addresses if the maskSensitiveData preference is enabled', async () => {
+ queryMocks.usePreferences.mockReturnValue({
+ data: preferences,
+ });
+
+ const { getAllByTestId, getAllByText, getByText } = renderWithTheme(
+
+ );
+
+ const visibilityToggles = getAllByTestId('VisibilityIcon');
+
+ // First IP address should be masked
+ expect(getAllByText('•••••••••••••••')[0]).toBeVisible();
+
+ await userEvent.click(visibilityToggles[0]);
+
+ // First IP address should be unmasked; second IP address should still be masked
+ expect(getByText('8.8.8.8')).toBeVisible();
+ expect(getByText('•••••••••••••••')).toBeVisible();
+
+ await userEvent.click(visibilityToggles[1]);
+
+ // Second IP address should be unmasked
+ expect(getByText('8.8.40.4')).toBeVisible();
+ });
+
+ it('should mask IP addresses if the maskSensitiveData preference is enabled and showMore is enabled', async () => {
+ queryMocks.usePreferences.mockReturnValue({
+ data: preferences,
+ });
+
+ const {
+ container,
+ getAllByTestId,
+ getAllByText,
+ getByText,
+ queryByText,
+ } = renderWithTheme(
+
+ );
+
+ const visibilityToggles = getAllByTestId('VisibilityIcon');
+
+ // First IP address should be masked but visible
+ expect(getAllByText('•••••••••••••••')[0]).toBeVisible();
+
+ await userEvent.click(visibilityToggles[0]);
+
+ // First IP address should be unmasked
+ expect(getByText('8.8.8.8')).toBeVisible();
+
+ // Show more button should be visible
+ expect(queryByText('8.8.40.4')).toBeNull();
+ expect(
+ container.querySelector('[data-qa-show-more-chip]')
+ ).toBeInTheDocument();
+ });
+});
diff --git a/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.tsx b/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.tsx
index 2bfd5a19ea1..12cbecb7f21 100644
--- a/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.tsx
+++ b/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.tsx
@@ -3,6 +3,7 @@ import * as React from 'react';
import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip';
import { ShowMore } from 'src/components/ShowMore/ShowMore';
import { PublicIPAddressesTooltip } from 'src/features/Linodes/PublicIPAddressesTooltip';
+import { usePreferences } from 'src/queries/profile/preferences';
import { isPrivateIP } from 'src/utilities/ipUtils';
import { tail } from 'src/utilities/tail';
@@ -77,6 +78,8 @@ export const IPAddress = (props: IPAddressProps) => {
false
);
+ const { data: preferences } = usePreferences();
+
React.useEffect(() => {
return () => {
if (copiedTimeout !== null) {
@@ -123,6 +126,8 @@ export const IPAddress = (props: IPAddressProps) => {
copyableText
data-qa-copy-ip-text
disabled={disabled}
+ masked={Boolean(preferences?.maskSensitiveData)}
+ maskedTextLength="ipv4"
text={ip}
/>
{renderCopyIcon(ip)}
@@ -133,7 +138,6 @@ export const IPAddress = (props: IPAddressProps) => {
return (
{!showAll ? renderIP(formattedIPS[0]) : formattedIPS.map(renderIP)}
-
{formattedIPS.length > 1 && showMore && !showAll && (
-
- {storageKeyData.access_key}
+
+
+
+ {storageKeyData.access_key}
+
+
-
+
{isObjMultiClusterEnabled && (
) : null}
{hostname && (
-
-
- {truncateMiddle(hostname, 50)}
-
-
-
+
+
+
+ {truncateMiddle(hostname, 50)}
+
+
+
+
)}
{(formattedCreated || cluster) && (
diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx
index 3f9aee4ab2d..2aee34d0013 100644
--- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx
+++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx
@@ -3,6 +3,7 @@ import * as React from 'react';
import { DateTimeDisplay } from 'src/components/DateTimeDisplay';
import { Hidden } from 'src/components/Hidden';
+import { MaskableText } from 'src/components/MaskableText/MaskableText';
import { TableCell } from 'src/components/TableCell';
import { Typography } from 'src/components/Typography';
import { useAccountManagement } from 'src/hooks/useAccountManagement';
@@ -70,23 +71,25 @@ export const BucketTableRow = (props: BucketTableRowProps) => {
return (
-
-
-
-
-
- {label}{' '}
-
-
-
-
- {hostname}
+
+
+
+
+
+
+ {label}{' '}
+
+
+
+
+ {hostname}
+
-
+
diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx
index 15d4f289d7c..89d4f78fd56 100644
--- a/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx
+++ b/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx
@@ -7,6 +7,7 @@ import * as React from 'react';
import { Button } from 'src/components/Button/Button';
import { LinkButton } from 'src/components/LinkButton';
+import { MaskableText } from 'src/components/MaskableText/MaskableText';
import { TextField } from 'src/components/TextField';
import { Typography } from 'src/components/Typography';
import {
@@ -214,9 +215,14 @@ export const PhoneVerification = ({
- {profile?.verified_phone_number
- ? getFormattedNumber(profile.verified_phone_number)
- : 'No Phone Number'}
+ {
return (
<>
{label}
-
- {questionResponse?.question}
+
+
Edit
diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/TrustedDevices.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/TrustedDevices.tsx
index 3279aa424e5..4a8773e9c0f 100644
--- a/packages/manager/src/features/Profile/AuthenticationSettings/TrustedDevices.tsx
+++ b/packages/manager/src/features/Profile/AuthenticationSettings/TrustedDevices.tsx
@@ -1,9 +1,9 @@
-import { Theme } from '@mui/material/styles';
import * as React from 'react';
import { makeStyles } from 'tss-react/mui';
import { DateTimeDisplay } from 'src/components/DateTimeDisplay';
import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction';
+import { MaskableText } from 'src/components/MaskableText/MaskableText';
import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter';
import { Table } from 'src/components/Table';
import { TableBody } from 'src/components/TableBody';
@@ -21,6 +21,8 @@ import { useTrustedDevicesQuery } from 'src/queries/profile/profile';
import { RevokeTrustedDeviceDialog } from './RevokeTrustedDevicesDialog';
+import type { Theme } from '@mui/material/styles';
+
const useStyles = makeStyles()((theme: Theme) => ({
copy: {
lineHeight: '20px',
@@ -87,8 +89,12 @@ const TrustedDevices = () => {
return data?.data.map((device) => {
return (
- {device.user_agent}
- {device.last_remote_addr}
+
+
+
+
+
+
diff --git a/packages/manager/src/features/Profile/Settings/Settings.tsx b/packages/manager/src/features/Profile/Settings/Settings.tsx
index 110734ff1f0..655e3e758ee 100644
--- a/packages/manager/src/features/Profile/Settings/Settings.tsx
+++ b/packages/manager/src/features/Profile/Settings/Settings.tsx
@@ -49,7 +49,9 @@ export const ProfileSettings = () => {
preferences?.type_to_confirm === undefined ||
preferences?.type_to_confirm === true;
+ // Email notifications and masking sensitive data are disabled by default until the user explicitly enables it.
const areEmailNotificationsEnabled = profile?.email_notifications === true;
+ const isSensitiveDataMasked = preferences?.maskSensitiveData === true;
return (
@@ -118,6 +120,27 @@ export const ProfileSettings = () => {
}`}
/>
+
+
+ Mask Sensitive Data
+
+
+ Mask IP addresses and user contact information for data privacy.
+
+
+ updatePreferences({ maskSensitiveData: checked })
+ }
+ checked={isSensitiveDataMasked}
+ />
+ }
+ label={`Sensitive data is ${
+ isSensitiveDataMasked ? 'masked' : 'visible'
+ }`}
+ />
+ {
const items = [
{
label: 'Username',
- value: {user.username},
+ value: ,
},
{
label: 'Email',
- value: {user.email},
+ value: ,
},
{
label: 'Account Access',
@@ -69,7 +70,12 @@ export const UserDetailsPanel = ({ user }: Props) => {
},
{
label: 'Verified Phone Number',
- value: {user.verified_phone_number ?? 'None'},
+ value: (
+
+ ),
},
{
label: 'SSH Keys',
diff --git a/packages/manager/src/features/Users/UserRow.tsx b/packages/manager/src/features/Users/UserRow.tsx
index c98039fe58f..e49877489e9 100644
--- a/packages/manager/src/features/Users/UserRow.tsx
+++ b/packages/manager/src/features/Users/UserRow.tsx
@@ -6,6 +6,7 @@ import { Avatar } from 'src/components/Avatar/Avatar';
import { Chip } from 'src/components/Chip';
import { DateTimeDisplay } from 'src/components/DateTimeDisplay';
import { Hidden } from 'src/components/Hidden';
+import { MaskableText } from 'src/components/MaskableText/MaskableText';
import { Stack } from 'src/components/Stack';
import { StatusIcon } from 'src/components/StatusIcon/StatusIcon';
import { TableCell } from 'src/components/TableCell';
@@ -44,13 +45,16 @@ export const UserRow = ({ onDelete, user }: Props) => {
}
username={user.username}
/>
- {user.username}
+
{user.tfa_enabled && }
- {user.email}
+
+ {' '}
+
+ {user.restricted ? 'Limited' : 'Full'}
{showChildAccountAccessCol && (
diff --git a/packages/manager/src/types/ManagerPreferences.ts b/packages/manager/src/types/ManagerPreferences.ts
index 3b97d993996..5c89f454313 100644
--- a/packages/manager/src/types/ManagerPreferences.ts
+++ b/packages/manager/src/types/ManagerPreferences.ts
@@ -27,6 +27,7 @@ export interface ManagerPreferences extends UserPreferences {
linodes_view_style?: 'grid' | 'list';
longviewTimeRange?: string;
main_content_banner_dismissal?: Record;
+ maskSensitiveData?: boolean;
nodebalancers_group_by_tag?: boolean;
pageSizes?: Record;
secure_vm_notices?: 'always' | 'header' | 'never';
diff --git a/packages/manager/src/utilities/createMaskedText.test.ts b/packages/manager/src/utilities/createMaskedText.test.ts
new file mode 100644
index 00000000000..2fda618be18
--- /dev/null
+++ b/packages/manager/src/utilities/createMaskedText.test.ts
@@ -0,0 +1,35 @@
+import {
+ DEFAULT_MASKED_TEXT_LENGTH,
+ MASKABLE_TEXT_LENGTH_MAP,
+ createMaskedText,
+} from './createMaskedText';
+
+describe('createMaskedText', () => {
+ it('should return a masked string the same length as the original string', () => {
+ const plainTextString = 'hello world';
+ const maskedString = createMaskedText(plainTextString, 'plaintext');
+ expect(maskedString.length).toEqual(plainTextString.length);
+ expect(maskedString).toBe('•••••••••••');
+ });
+
+ it('should return a masked string with a default length if no length provided and plaintext is shorter than default', () => {
+ const plainTextString = 'hello world';
+ const maskedString = createMaskedText(plainTextString);
+ expect(maskedString.length).toEqual(DEFAULT_MASKED_TEXT_LENGTH);
+ expect(maskedString).toBe('••••••••••••');
+ });
+
+ it('should return a masked string with a default length if no length provided and plaintext is longer than default', () => {
+ const plainTextString = 'hello world, goodbye world';
+ const maskedString = createMaskedText(plainTextString);
+ expect(maskedString.length).toEqual(DEFAULT_MASKED_TEXT_LENGTH);
+ expect(maskedString).toBe('••••••••••••');
+ });
+
+ it('should return a masked string with a default length for ipv4', () => {
+ const plainTextString = '123.456.789.123';
+ const maskedString = createMaskedText(plainTextString, 'ipv4');
+ expect(maskedString.length).toEqual(MASKABLE_TEXT_LENGTH_MAP.get('ipv4'));
+ expect(maskedString).toBe('•••••••••••••••');
+ });
+});
diff --git a/packages/manager/src/utilities/createMaskedText.ts b/packages/manager/src/utilities/createMaskedText.ts
new file mode 100644
index 00000000000..94c54433c80
--- /dev/null
+++ b/packages/manager/src/utilities/createMaskedText.ts
@@ -0,0 +1,23 @@
+import type { MaskableTextLength } from 'src/components/MaskableText/MaskableText';
+
+export const DEFAULT_MASKED_TEXT_LENGTH = 12;
+
+export const MASKABLE_TEXT_LENGTH_MAP: Map<
+ MaskableTextLength,
+ number
+> = new Map([
+ ['ipv4', 15],
+ ['ipv6', 30], // Max length of an ipv6 address is 45 characters, but dots take up more visual space.
+]);
+
+export const createMaskedText = (
+ plainText: string,
+ length?: MaskableTextLength
+) => {
+ // Mask a default of 12 dots, unless the prop specifies a different default or the plaintext length.
+ const MASKED_TEXT_LENGTH = !length
+ ? DEFAULT_MASKED_TEXT_LENGTH
+ : MASKABLE_TEXT_LENGTH_MAP.get(length) ?? plainText.length;
+
+ return '•'.repeat(MASKED_TEXT_LENGTH);
+};
diff --git a/packages/ui/src/components/VisibilityTooltip/VisibilityTooltip.stories.tsx b/packages/ui/src/components/VisibilityTooltip/VisibilityTooltip.stories.tsx
new file mode 100644
index 00000000000..18e3fa70840
--- /dev/null
+++ b/packages/ui/src/components/VisibilityTooltip/VisibilityTooltip.stories.tsx
@@ -0,0 +1,21 @@
+import React from 'react';
+
+import { VisibilityTooltip } from './VisibilityTooltip';
+
+import type { Meta, StoryObj } from '@storybook/react';
+
+const meta: Meta = {
+ component: VisibilityTooltip,
+ title: 'Components/Tooltip/Visibility Tooltip',
+};
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ isVisible: true,
+ },
+ render: (args) => ,
+};
+
+export default meta;
diff --git a/packages/ui/src/components/VisibilityTooltip/VisibilityTooltip.tsx b/packages/ui/src/components/VisibilityTooltip/VisibilityTooltip.tsx
new file mode 100644
index 00000000000..a6d43e97d94
--- /dev/null
+++ b/packages/ui/src/components/VisibilityTooltip/VisibilityTooltip.tsx
@@ -0,0 +1,67 @@
+import VisibilityIcon from '@mui/icons-material/Visibility';
+import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
+import { styled } from '@mui/material/styles';
+import React from 'react';
+
+import type { SxProps, Theme } from '@mui/material/styles';
+import { IconButton } from '../IconButton';
+import { Tooltip, TooltipProps } from '../Tooltip';
+
+interface Props {
+ /**
+ * Toggles visibility icon on click
+ */
+ handleClick: () => void;
+ /**
+ * If true, displays the icon to toggle visibility to hidden; if false, displays the icon to toggle visibility to shown.
+ */
+ isVisible: boolean;
+ /**
+ * Additional styles to apply to the component.
+ */
+ sx?: SxProps;
+ /**
+ * The placement of the tooltip.
+ */
+ placement?: TooltipProps['placement'];
+}
+/**
+ * Toggle-able visibility icon with tooltip on hover
+ */
+export const VisibilityTooltip = (props: Props) => {
+ const { handleClick, isVisible, sx, placement } = props;
+
+ return (
+
+
+ {!isVisible ? (
+
+ ) : (
+
+ )}
+
+
+ );
+};
+
+const StyledToggleButton = styled(IconButton, {
+ label: 'StyledToggleButton',
+})(({ theme }) => ({
+ '& svg': {
+ color: theme.palette.grey[500],
+ fontSize: '0.875rem',
+ },
+ '& svg:hover': {
+ color: theme.palette.primary.main,
+ },
+ fontSize: '0.875rem',
+ marginLeft: theme.spacing(),
+ minHeight: 'auto',
+ minWidth: 'auto',
+ padding: 0,
+}));
diff --git a/packages/ui/src/components/VisibilityTooltip/index.ts b/packages/ui/src/components/VisibilityTooltip/index.ts
new file mode 100644
index 00000000000..8816f6ee391
--- /dev/null
+++ b/packages/ui/src/components/VisibilityTooltip/index.ts
@@ -0,0 +1 @@
+export * from './VisibilityTooltip';
diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts
index d05c00c3913..0fd364db998 100644
--- a/packages/ui/src/components/index.ts
+++ b/packages/ui/src/components/index.ts
@@ -8,3 +8,4 @@ export * from './Input';
export * from './InputAdornment';
export * from './InputLabel';
export * from './Tooltip';
+export * from './VisibilityTooltip';