diff --git a/_locales/en/messages.json b/_locales/en/messages.json index d549335d69..65b90b2897 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -99,6 +99,22 @@ "messageformat": "Donate to Signal", "description": "The title of the preferences pane for donating to signal" }, + "icu:LinkedDevicesOnboardingDialog__description": { + "messageformat": "Linked Devices let you access your Signal account and messages securely on any of your additional devices.", + "description": "Description of the Linked Devices Onboarding Dialog" + }, + "icu:LinkedDevicesOnboardingDialog__bullet--1": { + "messageformat": "You can link a desktop, tablet or additional phone.", + "description": "First bullet point of the Linked Devices Onboarding Dialog" + }, + "icu:LinkedDevicesOnboardingDialog__bullet--2": { + "messageformat": "Your primary device manages what’s linked, you can link up to 5 devices.", + "description": "Second bullet point of the Linked Devices Onboarding Dialog" + }, + "icu:LinkedDevicesOnboardingDialog__bullet--3": { + "messageformat": "Account settings are managed on your primary device only, including account settings and backups.", + "description": "Third bullet point of the Linked Devices Onboarding Dialog" + }, "icu:sentMediaQualityStandard": { "messageformat": "Standard", "description": "Label text for standard media quality option" @@ -8084,6 +8100,10 @@ "messageformat": "Notify when someone reacts to your message", "description": "Description of setting to enable/disable system notifications when someone reacts to your message" }, + "icu:Preferences__button--account": { + "messageformat": "Account", + "description": "Button to switch the settings view" + }, "icu:Preferences__button--general": { "messageformat": "General", "description": "Button to switch the settings view" @@ -8176,6 +8196,42 @@ "messageformat": "System", "description": "Title for system type settings" }, + "icu:Preferences--signal-login": { + "messageformat": "Signal Login", + "description": "Title for Signal Login part of Account settings" + }, + "icu:Preferences--signal-login-description": { + "messageformat": "Your Signal Login is used to recover and restore your account. Keep these keys stored safely in a password manager you trust. Learn more.", + "description": "Footer for Signal Login part of Account settings" + }, + "icu:Preferences--signal-login-description--learn-more": { + "messageformat": "Learn more", + "description": "Footer for Signal Login part of Account settings > Learn More" + }, + "icu:Preferences__SignalAccount__label": { + "messageformat": "Account & Recovery", + "description": "Label for Signal Login preferences item" + }, + "icu:Preferences__SignalAccount__description": { + "messageformat": "2 keys", + "description": "Description for Signal Login preferences item" + }, + "icu:Preferences__SignalAccountKeys__AccountKey": { + "messageformat": "Account key", + "description": "Label for the Account Key in Signal Account Keys preferences page" + }, + "icu:Preferences__SignalAccountKeys__RecoveryKey": { + "messageformat": "Recovery key", + "description": "Label for the Recovery Key in Signal Account Keys preferences page" + }, + "icu:Preferences__Account__LinkedDevice__label": { + "messageformat": "This is a linked device", + "description": "Label of linked device section of Account settings" + }, + "icu:Preferences__Account__LinkedDevice__description": { + "messageformat": "To manage your account settings, open Signal on your primary device.", + "description": "Description of linked device section of Account settings" + }, "icu:Preferences--zoom": { "messageformat": "Zoom level", "description": "Label for changing the zoom level" diff --git a/images/devices.svg b/images/devices.svg new file mode 100644 index 0000000000..c7a82d2fc1 --- /dev/null +++ b/images/devices.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/pin-reminder.svg b/images/pin-reminder.svg index 8ee3878008..e136093341 100644 --- a/images/pin-reminder.svg +++ b/images/pin-reminder.svg @@ -1,83 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/images/safety-tips/safety-tip-business.svg b/images/safety-tips/safety-tip-business.svg index 351fbafd66..84a9c8fe1d 100644 --- a/images/safety-tips/safety-tip-business.svg +++ b/images/safety-tips/safety-tip-business.svg @@ -1,22 +1 @@ - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/images/safety-tips/safety-tip-crypto.svg b/images/safety-tips/safety-tip-crypto.svg index 5cdf1374b2..8177f95f64 100644 --- a/images/safety-tips/safety-tip-crypto.svg +++ b/images/safety-tips/safety-tip-crypto.svg @@ -1,37 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/images/safety-tips/safety-tip-dont-respond.svg b/images/safety-tips/safety-tip-dont-respond.svg index 423e4d6060..231a663005 100644 --- a/images/safety-tips/safety-tip-dont-respond.svg +++ b/images/safety-tips/safety-tip-dont-respond.svg @@ -1,21 +1 @@ - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/images/safety-tips/safety-tip-icon-chat-x.svg b/images/safety-tips/safety-tip-icon-chat-x.svg index b878fdd679..2d2357e3dd 100644 --- a/images/safety-tips/safety-tip-icon-chat-x.svg +++ b/images/safety-tips/safety-tip-icon-chat-x.svg @@ -1,6 +1 @@ - - - - - - + \ No newline at end of file diff --git a/images/safety-tips/safety-tip-icon-person-question.svg b/images/safety-tips/safety-tip-icon-person-question.svg index bcf35858dd..9bb99f634b 100644 --- a/images/safety-tips/safety-tip-icon-person-question.svg +++ b/images/safety-tips/safety-tip-icon-person-question.svg @@ -1,9 +1 @@ - - - - - - - - - + \ No newline at end of file diff --git a/images/safety-tips/safety-tip-icon-raised-hand.svg b/images/safety-tips/safety-tip-icon-raised-hand.svg index 82e56890b9..389d58d622 100644 --- a/images/safety-tips/safety-tip-icon-raised-hand.svg +++ b/images/safety-tips/safety-tip-icon-raised-hand.svg @@ -1,5 +1 @@ - - - - - + \ No newline at end of file diff --git a/images/safety-tips/safety-tip-links.svg b/images/safety-tips/safety-tip-links.svg index 5b2223bfa2..8cf5bb901f 100644 --- a/images/safety-tips/safety-tip-links.svg +++ b/images/safety-tips/safety-tip-links.svg @@ -1,17 +1 @@ - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/images/safety-tips/safety-tip-review-names.svg b/images/safety-tips/safety-tip-review-names.svg index ebd8e7d37c..dc4b0b0127 100644 --- a/images/safety-tips/safety-tip-review-names.svg +++ b/images/safety-tips/safety-tip-review-names.svg @@ -1,20 +1 @@ - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/images/safety-tips/safety-tip-vague.svg b/images/safety-tips/safety-tip-vague.svg index 3c6b1e4946..90fb98b050 100644 --- a/images/safety-tips/safety-tip-vague.svg +++ b/images/safety-tips/safety-tip-vague.svg @@ -1,18 +1 @@ - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/images/signal-login.svg b/images/signal-login.svg new file mode 100644 index 0000000000..9611bd199e --- /dev/null +++ b/images/signal-login.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/signal-logo-with-text.svg b/images/signal-logo-with-text.svg index 74ce07f47c..72d66f7493 100644 --- a/images/signal-logo-with-text.svg +++ b/images/signal-logo-with-text.svg @@ -1,9 +1 @@ - - - - - - - - - + \ No newline at end of file diff --git a/images/warning-circle.svg b/images/warning-circle.svg index 2080ea470e..2ca0b097a9 100644 --- a/images/warning-circle.svg +++ b/images/warning-circle.svg @@ -1,7 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/ts/components/Preferences.dom.stories.tsx b/ts/components/Preferences.dom.stories.tsx index 2b1c7acd21..2c70f0f38f 100644 --- a/ts/components/Preferences.dom.stories.tsx +++ b/ts/components/Preferences.dom.stories.tsx @@ -652,7 +652,10 @@ export default { pickLocalBackupFolder: () => Promise.resolve('/home/signaluser/Signal Backups/'), disableLocalBackups: () => Promise.resolve(), - promptOSAuth: () => Promise.resolve('success'), + promptOSAuth: async () => { + await sleep(1000); + return 'success'; + }, refreshCloudBackupStatus: action('refreshCloudBackupStatus'), refreshBackupSubscriptionStatus: action('refreshBackupSubscriptionStatus'), removeCustomColor: action('removeCustomColor'), @@ -742,6 +745,22 @@ const Template: StoryFn = args => { export const _Preferences = Template.bind({}); +export const Account = Template.bind({}); +Account.args = { + settingsLocation: { page: SettingsPage.Account }, +}; +export const AccountE164Less = Template.bind({}); +AccountE164Less.args = { + phoneNumber: undefined, + me: { ...me, phoneNumber: undefined }, + settingsLocation: { page: SettingsPage.Account }, +}; +export const AccountKeys = Template.bind({}); +AccountKeys.args = { + phoneNumber: undefined, + me: { ...me, phoneNumber: undefined }, + settingsLocation: { page: SettingsPage.AccountKeys }, +}; export const General = Template.bind({}); General.args = { settingsLocation: { page: SettingsPage.General }, diff --git a/ts/components/Preferences.dom.tsx b/ts/components/Preferences.dom.tsx index 9be3c61af8..c21d654fa0 100644 --- a/ts/components/Preferences.dom.tsx +++ b/ts/components/Preferences.dom.tsx @@ -22,7 +22,10 @@ import { WidthBreakpoint } from './_util.std.ts'; import { DisappearingTimeDialog } from './DisappearingTimeDialog.dom.tsx'; import { PhoneNumberDiscoverability } from '../util/phoneNumberDiscoverability.std.ts'; import { PhoneNumberSharingMode } from '../types/PhoneNumberSharingMode.std.ts'; -import { KEY_TRANSPARENCY_URL } from '../types/support.std.ts'; +import { + KEY_TRANSPARENCY_URL, + RESTORE_ACCOUNT_URL, +} from '../types/support.std.ts'; import { getCustomColorStyle } from '../util/getCustomColorStyle.dom.ts'; import { DEFAULT_DURATIONS_IN_SECONDS, @@ -98,6 +101,9 @@ import type { LocalBackupExportMetadata } from '../types/LocalExport.std.ts'; import { isDonationsPage } from './PreferencesDonations.dom.tsx'; import type { VisibleRemoteMegaphoneType } from '../types/Megaphone.std.ts'; import { TitlebarDragArea } from './TitlebarDragArea.dom.tsx'; +import { LinkedDevicesOnboardingDialog } from './preferences/LinkedDevicesOnboardingDialog.dom.tsx'; +import { SignalAccountKeys } from './preferences/SignalAccountKeys.dom.tsx'; +import { SignalAccountKeysButton } from './preferences/SignalAccountKeysButton.dom.tsx'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges.preload.ts'; import { Emoji } from '../axo/emoji.std.ts'; import { AxoAlertDialog } from '../axo/AxoAlertDialog.dom.tsx'; @@ -678,6 +684,7 @@ export function Preferences({ const [languageSearchInput, setLanguageSearchInput] = useState(''); const [confirmPnpNotDiscoverable, setConfirmPnpNoDiscoverable] = useState(false); + const [linkedDevicesOnboarding, setLinkedDevicesOnboarding] = useState(false); const handleOpenEditChatFoldersPage = useCallback( (chatFolderId: ChatFolderId | null) => { @@ -863,16 +870,111 @@ export function Preferences({ content = renderProfileEditor({ contentsRef: settingsPaneRef, }); + } else if (settingsLocation.page === SettingsPage.Account) { + const pageContents = ( + + {!weArePrimaryDevice && ( + <> + + setLinkedDevicesOnboarding(true)} + /> + + {linkedDevicesOnboarding && ( + setLinkedDevicesOnboarding(false)} + /> + )} + > + )} + {!phoneNumber && ( + ( + + {i18n( + 'icu:Preferences--signal-login-description--learn-more' + )} + + ), + }} + /> + } + > + { + setSettingsLocation({ page: SettingsPage.AccountKeys }); + }} + /> + + )} + + ); + content = ( + + ); + } else if (settingsLocation.page === SettingsPage.AccountKeys) { + const backButton = ( + setSettingsLocation({ page: SettingsPage.Account })} + type="button" + /> + ); + + const pageContents = ( + + + + ); + content = ( + + ); } else if (settingsLocation.page === SettingsPage.General) { const pageContents = ( - + {phoneNumber && ( + + )} {me.title} - - {me.phoneNumber} - + {me.phoneNumber && ( + + {me.phoneNumber} + + )} {me.username && ( {me.username} @@ -2526,6 +2630,28 @@ export function Preferences({ )} + {(!weArePrimaryDevice || !phoneNumber) && ( + + setSettingsLocation({ page: SettingsPage.Account }) + } + /> + )} + + setSettingsLocation({ page: SettingsPage.Donations }) + } + /> + - - setSettingsLocation({ page: SettingsPage.Donations }) - } - /> {isInternalUser ? ( ) : null} - {authError ? ( - { - if (!open) { - setAuthError(undefined); - } - }} - > - - - {i18n('icu:Toast--error')} - - - - {i18n( - 'icu:Preferences__local-backups-auth-error--unauthorized' - )} - - - - {i18n('icu:ok')} - - - - ) : null} + setAuthError(newOpen ? 'error' : undefined)} + /> {isShowingBackupKeyChangedModal ? ( + ); +} diff --git a/ts/components/preferences/LinkedDevicesOnboardingDialog.dom.tsx b/ts/components/preferences/LinkedDevicesOnboardingDialog.dom.tsx new file mode 100644 index 0000000000..0f47cf9a3d --- /dev/null +++ b/ts/components/preferences/LinkedDevicesOnboardingDialog.dom.tsx @@ -0,0 +1,61 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import type { ReactNode } from 'react'; +import { tw } from '../../axo/tw.dom.tsx'; +import type { LocalizerType } from '../../types/I18N.std.ts'; +import { AxoDialog } from '../../axo/AxoDialog.dom.tsx'; +import { I18n } from '../I18n.dom.tsx'; + +export type LinkedDevicesOnboardingDialogsProps = Readonly<{ + i18n: LocalizerType; + onDismiss: VoidFunction; +}>; + +export function LinkedDevicesOnboardingDialog( + props: LinkedDevicesOnboardingDialogsProps +): ReactNode { + const { i18n, onDismiss } = props; + return ( + + + + + + + + + + + + {children}, + }} + /> + + li]:mt-3')}> + {i18n('icu:LinkedDevicesOnboardingDialog__bullet--1')} + {i18n('icu:LinkedDevicesOnboardingDialog__bullet--2')} + {i18n('icu:LinkedDevicesOnboardingDialog__bullet--3')} + + + + + + ); +} diff --git a/ts/components/preferences/OSAuthErrorDialog.dom.tsx b/ts/components/preferences/OSAuthErrorDialog.dom.tsx new file mode 100644 index 0000000000..ba3d33fefa --- /dev/null +++ b/ts/components/preferences/OSAuthErrorDialog.dom.tsx @@ -0,0 +1,36 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { ReactNode } from 'react'; +import type { LocalizerType } from '../../types/Util.std.ts'; +import { AxoAlertDialog } from '../../axo/AxoAlertDialog.dom.tsx'; + +type OSAuthErrorDialogPropsType = Readonly<{ + i18n: LocalizerType; + open: boolean; + onOpenChange: (newOpen: boolean) => void; +}>; + +export function OSAuthErrorDialog({ + i18n, + open, + onOpenChange, +}: OSAuthErrorDialogPropsType): ReactNode { + return ( + + + + {i18n('icu:Toast--error')} + + + + {i18n('icu:Preferences__local-backups-auth-error--unauthorized')} + + + + {i18n('icu:ok')} + + + + ); +} diff --git a/ts/components/preferences/SignalAccountKeys.dom.tsx b/ts/components/preferences/SignalAccountKeys.dom.tsx new file mode 100644 index 0000000000..3fffd6e93f --- /dev/null +++ b/ts/components/preferences/SignalAccountKeys.dom.tsx @@ -0,0 +1,79 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { ReactNode } from 'react'; +import { tw } from '../../axo/tw.dom.tsx'; +import type { LocalizerType } from '../../types/Util.std.ts'; +import { formatBackupKeyForDisplay } from '../../util/formatBackupKeyForDisplay.std.ts'; + +type SignalAccountKeysPropsType = Readonly<{ + i18n: LocalizerType; + serviceId: string | undefined; + backupKey: string | undefined; +}>; + +export function SignalAccountKeys({ + i18n, + serviceId, + backupKey, +}: SignalAccountKeysPropsType): ReactNode { + const backupKeyForDisplay = + backupKey && + formatBackupKeyForDisplay(backupKey, { + convertAmbiguousChars: true, + }); + + return ( + <> + + + {serviceId && ( + + )} + + {backupKeyForDisplay && ( + + )} + > + ); +} + +function KeySection({ + label, + value, +}: { + label: string; + value: string; +}): ReactNode { + return ( + + + {label} + + + {value} + + + ); +} diff --git a/ts/components/preferences/SignalAccountKeysButton.dom.tsx b/ts/components/preferences/SignalAccountKeysButton.dom.tsx new file mode 100644 index 0000000000..becaddeacf --- /dev/null +++ b/ts/components/preferences/SignalAccountKeysButton.dom.tsx @@ -0,0 +1,98 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { useId, useCallback, useState } from 'react'; +import type { ReactNode } from 'react'; + +import { tw } from '../../axo/tw.dom.tsx'; +import { createLogger } from '../../logging/log.std.ts'; +import type { LocalizerType } from '../../types/Util.std.ts'; +import { toLogFormat } from '../../types/errors.std.ts'; +import type { + PromptOSAuthReasonType, + PromptOSAuthResultType, +} from '../../util/os/promptOSAuthMain.main.ts'; +import { AxoItem } from '../../axo/items/AxoItem.dom.tsx'; +import { OSAuthErrorDialog } from './OSAuthErrorDialog.dom.tsx'; + +type SignalAccountKeysButtonPropsType = Readonly<{ + i18n: LocalizerType; + promptOSAuth: ( + reason: PromptOSAuthReasonType + ) => Promise; + onClick: () => void; +}>; + +const log = createLogger('SignalAccountKeysButton'); + +export function SignalAccountKeysButton({ + i18n, + promptOSAuth, + onClick: showAccountKeys, +}: SignalAccountKeysButtonPropsType): ReactNode { + const id = useId(); + + const [authError, setAuthError] = + useState>(); + const [isAuthPending, setIsAuthPending] = useState(false); + + const onClick = useCallback(async () => { + setAuthError(undefined); + + try { + setIsAuthPending(true); + const result = await promptOSAuth('view-aep'); + if (result === 'success' || result === 'unsupported') { + showAccountKeys(); + } else { + setAuthError(result); + } + } catch (e) { + log.error( + 'Error thrown when requesting OS auth for viewing AEP', + toLogFormat(e) + ); + setAuthError('error'); + // oxlint-disable-next-line react/todo + } finally { + setIsAuthPending(false); + } + }, [promptOSAuth, showAccountKeys]); + + return ( + <> + + + + + + + + {i18n('icu:Preferences__SignalAccount__label')} + + + {i18n('icu:Preferences__SignalAccount__description')} + + + + + + + + + { + setAuthError(newOpen ? 'error' : undefined); + }} + /> + > + ); +} diff --git a/ts/types/Nav.std.ts b/ts/types/Nav.std.ts index b95ded9768..8a5f13cc3d 100644 --- a/ts/types/Nav.std.ts +++ b/ts/types/Nav.std.ts @@ -67,6 +67,7 @@ export enum NavTab { export enum SettingsPage { // Accessible through left nav Profile = 'Profile', + Account = 'Account', General = 'General', Donations = 'Donations', Appearance = 'Appearance', @@ -79,6 +80,7 @@ export enum SettingsPage { Internal = 'Internal', // Sub pages + AccountKeys = 'AccountKeys', Blocked = 'Blocked', ChatColor = 'ChatColor', ChatFolders = 'ChatFolders', diff --git a/ts/types/support.std.ts b/ts/types/support.std.ts index 748f5bdc14..ebd06013b1 100644 --- a/ts/types/support.std.ts +++ b/ts/types/support.std.ts @@ -13,3 +13,5 @@ export const KEY_TRANSPARENCY_URL = 'https://support.signal.org/hc/articles/10223569377562'; export const SYNCING_MESSAGES_SECURITY_URL = 'https://support.signal.org/hc/articles/360007320391'; +export const RESTORE_ACCOUNT_URL = + 'https://support.signal.org/hc/articles/11197884108826/';
+ {children}, + }} + /> +