TelegramPWA/src/components/left/settings/SettingsPasskeys.tsx
2026-02-22 23:49:11 +01:00

187 lines
5.5 KiB
TypeScript

import {
memo,
useEffect,
useState,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiPasskey } from '../../../api/types';
import { IS_WEBAUTHN_SUPPORTED } from '../../../util/browser/windowEnvironment';
import buildClassName from '../../../util/buildClassName';
import { formatPastDatetime } from '../../../util/dates/dateFormat';
import { getNextArrowReplacement } from '../../../util/localization/format';
import { LOCAL_TGS_PREVIEW_URLS, LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets';
import { REM } from '../../common/helpers/mediaDimensions';
import useHistoryBack from '../../../hooks/useHistoryBack';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import AnimatedIconWithPreview from '../../common/AnimatedIconWithPreview';
import CustomEmoji from '../../common/CustomEmoji';
import Icon from '../../common/icons/Icon';
import Button from '../../ui/Button';
import ConfirmDialog from '../../ui/ConfirmDialog';
import Link from '../../ui/Link';
import ListItem from '../../ui/ListItem';
import styles from './SettingsPasskeys.module.scss';
type OwnProps = {
isActive?: boolean;
onReset: () => void;
};
type StateProps = {
passkeys?: ApiPasskey[];
maxPasskeysCount: number;
};
const TOP_STICKER_SIZE = 120;
const ICON_SIZE = 2 * REM;
const SettingsPasskeys = ({
isActive,
passkeys,
maxPasskeysCount,
onReset,
}: OwnProps & StateProps) => {
const {
startPasskeyRegistration,
deletePasskey,
openPasskeyModal,
} = getActions();
const lang = useLang();
const [deleteModalId, setDeleteModalId] = useState<string>();
const canAddPasskey = IS_WEBAUTHN_SUPPORTED && (passkeys?.length ?? 0) < maxPasskeysCount;
const handleCreatePasskey = useLastCallback(() => {
startPasskeyRegistration();
});
const handleOpenPasskeyModal = useLastCallback(() => {
openPasskeyModal();
});
const confirmDeletePasskey = useLastCallback(() => {
if (!deleteModalId) return;
deletePasskey({ id: deleteModalId });
setDeleteModalId(undefined);
});
useEffect(() => {
if (!passkeys || passkeys.length || !isActive) return;
onReset(); // Autoclose when last passkey is deleted
}, [passkeys, onReset, isActive]);
useHistoryBack({
isActive,
onBack: onReset,
});
function renderPasskey(passkey: ApiPasskey) {
const { softwareEmojiId, id, name, date, lastUsageDate } = passkey;
return (
<ListItem
key={id}
ripple
narrow
contextActions={[{
title: lang('Delete'),
icon: 'delete',
destructive: true,
handler: () => {
setDeleteModalId(id);
},
}]}
leftElement={softwareEmojiId ? (
<CustomEmoji
size={ICON_SIZE}
className={buildClassName(styles.icon, 'ListItem-main-icon')}
documentId={softwareEmojiId}
noPlay
/>
) : (
<Icon name="lock" className={buildClassName(styles.fallbackIcon, 'ListItem-main-icon')} />
)}
>
<div className="multiline-item full-size" dir="auto">
<span className="date">{formatPastDatetime(lang, date)}</span>
<span className="title">{name || lang('SettingsPasskeyFallbackTitle')}</span>
{Boolean(lastUsageDate) && (
<span className="subtitle">
{lang('SettingsPasskeyUsedAt', {
date: formatPastDatetime(lang, lastUsageDate),
})}
</span>
)}
</div>
</ListItem>
);
}
return (
<div className="settings-content custom-scroll">
<div className="settings-content-header">
<AnimatedIconWithPreview
tgsUrl={LOCAL_TGS_URLS.Passkeys}
previewUrl={LOCAL_TGS_PREVIEW_URLS.Passkeys}
size={TOP_STICKER_SIZE}
className="settings-content-icon"
/>
<p className="settings-item-description" dir="auto">
{lang('SettingsPasskeyInfo')}
</p>
</div>
<div className="settings-item">
{passkeys?.map(renderPasskey)}
{canAddPasskey && (
<Button
className="settings-button"
color="primary"
iconName="add"
isText
noForcedUpperCase
onClick={handleCreatePasskey}
>
{lang('SettingsPasskeysCreate')}
</Button>
)}
<p className="settings-item-description mt-3" dir="auto">
{lang('SettingsPasskeysFooter', {
link: (
<Link isPrimary onClick={handleOpenPasskeyModal}>
{lang('SettingsPasskeysFooterLink', undefined,
{ withNodes: true, specialReplacement: getNextArrowReplacement() })}
</Link>
),
}, { withNodes: true })}
</p>
</div>
<ConfirmDialog
isOpen={Boolean(deleteModalId)}
title={lang('PasskeyDeleteTitle')}
textParts={lang('PasskeyDeleteText', undefined, { withNodes: true, renderTextFilters: ['br'] })}
confirmHandler={confirmDeletePasskey}
confirmIsDestructive
confirmLabel={lang('Delete')}
onClose={() => setDeleteModalId(undefined)}
/>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global): Complete<StateProps> => {
return {
passkeys: global.settings.passkeys,
maxPasskeysCount: global.appConfig.passkeysMaxCount,
};
},
)(SettingsPasskeys));