Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions packages/pass/components/Form/Field/Control/ExtraFieldsControl.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { PropsWithChildren } from 'react';
import type { PropsWithChildren, ReactElement } from 'react';
import { type FC, useCallback } from 'react';
import { useSelector } from 'react-redux';

import type { IconName } from '@proton/icons/types';
import { getExtraFieldOption } from '@proton/pass/components/Form/Field/ExtraFieldGroup/ExtraField.utils';
import { FieldsetCluster } from '@proton/pass/components/Form/Field/Layout/FieldsetCluster';
import { TextAreaReadonly } from '@proton/pass/components/Form/legacy/TextAreaReadonly';
Expand All @@ -21,6 +22,10 @@ type ExtraFieldsControlProps = {
shareId: string;
hideIcons?: boolean;
onCopy?: () => void;
/** disables per-field click-to-copy (eg: while multi-copy selection is enabled) */
copyable?: boolean;
/** overrides the field icon - used to render the multi-copy selection checkbox */
renderIcon?: (key: string) => IconName | ReactElement | undefined;
};

export const ExtraFieldsControl: FC<PropsWithChildren<ExtraFieldsControlProps>> = ({
Expand All @@ -30,13 +35,15 @@ export const ExtraFieldsControl: FC<PropsWithChildren<ExtraFieldsControlProps>>
itemId,
shareId,
onCopy,
copyable = true,
renderIcon,
}) => {
const { needsUpgrade } = useSelector(selectExtraFieldLimits);

const getControlByType = useCallback(
({ fieldName, type, data }: DeobfuscatedItemExtraField, index: number) => {
const icon = hideIcons ? undefined : getExtraFieldOption(type).icon;
const key = `${index}-${fieldName}`;
const icon = renderIcon?.(key) ?? (hideIcons ? undefined : getExtraFieldOption(type).icon);

if (needsUpgrade) {
return (
Expand All @@ -62,7 +69,7 @@ export const ExtraFieldsControl: FC<PropsWithChildren<ExtraFieldsControlProps>>
<ValueControl icon={icon} key={key} label={fieldName} />
) : (
<ValueControl
clickToCopy
clickToCopy={copyable}
key={key}
icon={icon}
label={fieldName}
Expand All @@ -77,7 +84,7 @@ export const ExtraFieldsControl: FC<PropsWithChildren<ExtraFieldsControlProps>>
<ValueControl icon={icon} key={key} label={fieldName} />
) : (
<ValueControl
clickToCopy
clickToCopy={copyable}
as={TextAreaReadonly}
key={key}
hidden={type === 'hidden'}
Expand All @@ -89,7 +96,7 @@ export const ExtraFieldsControl: FC<PropsWithChildren<ExtraFieldsControlProps>>
);
}
},
[itemId, shareId]
[copyable, hideIcons, itemId, renderIcon, shareId]
);

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type FC, useRef } from 'react';
import type { ReactElement } from 'react';

import { c } from 'ttag';

Expand All @@ -11,7 +12,7 @@ import type { MaybeNull, OtpRequest } from '@proton/pass/types';

import { ValueControl } from './ValueControl';

type Props = { label?: string; payload: OtpRequest; onCopy?: () => void; icon?: IconName };
type Props = { label?: string; payload: OtpRequest; onCopy?: () => void; icon?: IconName | ReactElement };

export const OTPValueControl: FC<Props> = ({ label, icon, payload, onCopy }) => {
const otpRenderer = useRef<MaybeNull<IOtpRenderer>>(null);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { FC } from 'react';
import type { FC, ReactElement } from 'react';

import type { IconName } from '@proton/icons/types';
import { UpgradeButton } from '@proton/pass/components/Upsell/UpgradeButton';
Expand All @@ -7,7 +7,7 @@ import type { UpsellRef } from '@proton/pass/constants';
import { ValueControl } from './ValueControl';

type UpgradeControlProps = {
icon?: IconName;
icon?: IconName | ReactElement;
label: string;
upsellRef: UpsellRef;
};
Expand Down
171 changes: 138 additions & 33 deletions packages/pass/components/Item/Login/Login.content.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
import { type FC, useMemo, useState } from 'react';
import { type FC, type ReactElement, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';

import { c } from 'ttag';

import { Banner } from '@proton/atoms/Banner/Banner';
import { Href } from '@proton/atoms/Href/Href';
import { IcChevronRight } from '@proton/icons/icons/IcChevronRight';
import type { IconName } from '@proton/icons/types';
import { usePassCore } from '@proton/pass/components/Core/PassCoreProvider';
import { ExtraFieldsControl } from '@proton/pass/components/Form/Field/Control/ExtraFieldsControl';
import { OTPValueControl } from '@proton/pass/components/Form/Field/Control/OTPValueControl';
import { ValueControl } from '@proton/pass/components/Form/Field/Control/ValueControl';
import { FieldsetCluster } from '@proton/pass/components/Form/Field/Layout/FieldsetCluster';
import { TextAreaReadonly } from '@proton/pass/components/Form/legacy/TextAreaReadonly';
import {
MultiCopyCheckbox,
type MultiCopyField,
MultiCopyToolbar,
useMultiCopy,
} from '@proton/pass/components/Item/MultiCopy/MultiCopy';
import { PasskeyContentModal } from '@proton/pass/components/Item/Passkey/Passkey.modal';
import { PasswordStrength } from '@proton/pass/components/Password/PasswordStrength';
import { UpgradeButton } from '@proton/pass/components/Upsell/UpgradeButton';
Expand All @@ -27,6 +35,9 @@ import { selectAliasByAliasEmail, selectTOTPLimits } from '@proton/pass/store/se
import type { MaybeNull } from '@proton/pass/types';
import { TelemetryFieldType } from '@proton/pass/types/data/telemetry';
import { AutofillMode } from '@proton/pass/types/protobuf';
import { isEmptyString } from '@proton/pass/utils/string/is-empty-string';
import { formatYYYYMMDD } from '@proton/pass/utils/time/format';
import clsx from '@proton/utils/clsx';

export const LoginContent: FC<ItemContentProps<'login'>> = ({ revision, secureLinkItem = false }) => {
const { data: item, shareId, itemId } = revision;
Expand Down Expand Up @@ -57,9 +68,79 @@ export const LoginContent: FC<ItemContentProps<'login'>> = ({ revision, secureLi
);

const sendClipboardTelemetry = useLoginClipboardTelemetry(item);
const core = usePassCore();

const multiCopyFields = useMemo<MultiCopyField[]>(() => {
const fields: MultiCopyField[] = [];
const totpGetter = (uri: string) => async () =>
(await core.generateOTP({ totpUri: uri, type: 'uri' }))?.token ?? '';

if (itemEmail) {
fields.push({
key: 'email',
label: relatedAlias ? c('Label').t`Email (alias)` : c('Label').t`Email`,
value: itemEmail,
});
}

if (itemUsername) fields.push({ key: 'username', label: c('Label').t`Username`, value: itemUsername });
if (password) fields.push({ key: 'password', label: c('Label').t`Password`, value: password });
if (totpUri && totpAllowed) {
fields.push({ key: 'totp', label: c('Label').t`2FA token (TOTP)`, value: totpGetter(totpUri) });
}

sanitizedUrls.forEach((url, index) =>
fields.push({ key: `url-${index}`, label: c('Label').t`Website`, value: url.url })
);

if (note) fields.push({ key: 'note', label: c('Label').t`Note`, value: note });

extraFields.forEach((extraField, index) => {
const key = `${index}-${extraField.fieldName}`;
const { fieldName } = extraField;

if (extraField.type === 'text' || extraField.type === 'hidden') {
const { content } = extraField.data;
if (!isEmptyString(content)) fields.push({ key, label: fieldName, value: content });
} else if (extraField.type === 'totp') {
const { totpUri } = extraField.data;
if (!isEmptyString(totpUri)) fields.push({ key, label: fieldName, value: totpGetter(totpUri) });
} else if (extraField.type === 'timestamp') {
const { timestamp } = extraField.data;
if (!isEmptyString(timestamp)) {
fields.push({ key, label: fieldName, value: formatYYYYMMDD(timestamp) ?? timestamp });
}
}
});

return fields;
}, [core, extraFields, itemEmail, itemUsername, note, password, relatedAlias, sanitizedUrls, totpAllowed, totpUri]);

const multicopy = useMultiCopy(multiCopyFields);

/** while multi-copy selection is enabled, replace the field icon with a selection checkbox */
const selectionIcon = (key: string, icon: IconName | ReactElement) =>
multicopy.enabled ? (
<MultiCopyCheckbox checked={multicopy.isSelected(key)} onChange={() => multicopy.toggleField(key)} />
) : (
icon
);

return (
<>
{multiCopyFields.length > 0 && (
<MultiCopyToolbar
commaSeparator={multicopy.separator === 'comma'}
enabled={multicopy.enabled}
selectedCount={multicopy.selectedCount}
totalCount={multiCopyFields.length}
onCopy={multicopy.copySelected}
onToggleEnabled={multicopy.toggleEnabled}
onToggleSelectAll={multicopy.toggleSelectAll}
onToggleSeparator={multicopy.setCommaSeparator}
/>
)}

{!secureLinkItem &&
(passkeys ?? []).map((passkey) => (
<FieldsetCluster mode="read" key={passkey.keyId}>
Expand All @@ -83,8 +164,8 @@ export const LoginContent: FC<ItemContentProps<'login'>> = ({ revision, secureLi

{itemEmail && (
<ValueControl
clickToCopy
icon={relatedAlias ? 'alias' : 'envelope'}
clickToCopy={!multicopy.enabled}
icon={selectionIcon('email', relatedAlias ? 'alias' : 'envelope')}
label={relatedAlias ? c('Label').t`Email (alias)` : c('Label').t`Email`}
value={itemEmail}
onCopy={() => sendClipboardTelemetry?.(TelemetryFieldType.email)}
Expand All @@ -93,18 +174,18 @@ export const LoginContent: FC<ItemContentProps<'login'>> = ({ revision, secureLi

{itemUsername && (
<ValueControl
clickToCopy
icon="user"
clickToCopy={!multicopy.enabled}
icon={selectionIcon('username', 'user')}
label={c('Label').t`Username`}
value={itemUsername}
onCopy={() => sendClipboardTelemetry?.(TelemetryFieldType.username)}
/>
)}

<ValueControl
clickToCopy
clickToCopy={!multicopy.enabled}
hidden
icon="key"
icon={selectionIcon('password', 'key')}
label={c('Label').t`Password`}
value={password}
ellipsis={false}
Expand All @@ -122,7 +203,7 @@ export const LoginContent: FC<ItemContentProps<'login'>> = ({ revision, secureLi

{totpUri && totpAllowed && (
<OTPValueControl
icon="lock"
icon={selectionIcon('totp', 'lock')}
payload={{ totpUri, type: 'uri' }}
onCopy={() => sendClipboardTelemetry?.(TelemetryFieldType.totp)}
/>
Expand Down Expand Up @@ -153,40 +234,55 @@ export const LoginContent: FC<ItemContentProps<'login'>> = ({ revision, secureLi
}
</Banner>
)}
{sanitizedUrls.map((url, index) => (
<li
key={index}
className="w-full flex flex-column border border-weak bg-weak rounded-lg px-2 py-1 gap-1"
>
<p className="w-full text-ellipsis">
{isAutofillModeDataOfTypeUrl(url.mode) ? (
<Href
className="block text-ellipsis"
href={url.url}
key={url.url}
title={url.url}
>
{url.url}
</Href>
) : (
<span title={url.url}>{url.url}</span>
{sanitizedUrls.map((url, index) => {
const urlKey = `url-${index}`;

return (
<li
key={index}
className={clsx(
'w-full flex border border-weak bg-weak rounded-lg px-2 py-1 gap-1',
multicopy.enabled ? 'flex-row items-center' : 'flex-column'
)}
</p>
{url.mode === AutofillMode.Default ? null : (
<p className="color-weak text-ellipsis">{getModeDescription(url.mode)}</p>
)}
</li>
))}
>
{multicopy.enabled && (
<MultiCopyCheckbox
checked={multicopy.isSelected(urlKey)}
onChange={() => multicopy.toggleField(urlKey)}
/>
)}
<div className={clsx('flex flex-column gap-1', multicopy.enabled && 'w-full')}>
<p className="w-full text-ellipsis">
{isAutofillModeDataOfTypeUrl(url.mode) ? (
<Href
className="block text-ellipsis"
href={url.url}
key={url.url}
title={url.url}
>
{url.url}
</Href>
) : (
<span title={url.url}>{url.url}</span>
)}
</p>
{url.mode === AutofillMode.Default ? null : (
<p className="color-weak text-ellipsis">{getModeDescription(url.mode)}</p>
)}
</div>
</li>
);
})}
</ValueControl>
</FieldsetCluster>
)}

{note && (
<FieldsetCluster mode="read" as="div">
<ValueControl
clickToCopy
clickToCopy={!multicopy.enabled}
as={TextAreaReadonly}
icon="note"
icon={selectionIcon('note', 'note')}
label={c('Label').t`Note`}
value={note}
onCopy={() => sendClipboardTelemetry?.(TelemetryFieldType.note)}
Expand All @@ -199,6 +295,15 @@ export const LoginContent: FC<ItemContentProps<'login'>> = ({ revision, secureLi
extraFields={extraFields}
itemId={itemId}
shareId={shareId}
copyable={!multicopy.enabled}
renderIcon={(key) =>
multicopy.enabled ? (
<MultiCopyCheckbox
checked={multicopy.isSelected(key)}
onChange={() => multicopy.toggleField(key)}
/>
) : undefined
}
onCopy={() => sendClipboardTelemetry?.(TelemetryFieldType.customField)}
/>
)}
Expand Down
Loading