diff --git a/src/api/gramjs/apiBuilders/appConfig.ts b/src/api/gramjs/apiBuilders/appConfig.ts index 0e3985689..48f1e87e1 100644 --- a/src/api/gramjs/apiBuilders/appConfig.ts +++ b/src/api/gramjs/apiBuilders/appConfig.ts @@ -120,6 +120,8 @@ export interface GramJsAppConfig extends LimitsConfig { verify_age_min?: number; message_typing_draft_ttl?: number; contact_note_length_limit?: number; + settings_display_passkeys?: boolean; + passkeys_account_passkeys_max?: number; } function buildEmojiSounds(appConfig: GramJsAppConfig) { @@ -243,6 +245,8 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp verifyAgeCountry: appConfig.verify_age_country, verifyAgeMin: appConfig.verify_age_min, typingDraftTtl: appConfig.message_typing_draft_ttl, + arePasskeysAvailable: appConfig.settings_display_passkeys, + passkeysMaxCount: appConfig.passkeys_account_passkeys_max, }; return { diff --git a/src/api/gramjs/apiBuilders/misc.ts b/src/api/gramjs/apiBuilders/misc.ts index 57b198d3d..4099c1d09 100644 --- a/src/api/gramjs/apiBuilders/misc.ts +++ b/src/api/gramjs/apiBuilders/misc.ts @@ -7,6 +7,7 @@ import type { ApiCountry, ApiLanguage, ApiOldLangString, + ApiPasskey, ApiPendingSuggestion, ApiPrivacyKey, ApiPromoData, @@ -349,3 +350,14 @@ export function buildApiRestrictionReasons( { reason, text, platform }) => ({ reason, text, platform })); } + +export function buildApiPasskey(passkey: GramJs.TypePasskey): ApiPasskey { + const { id, name, date, softwareEmojiId, lastUsageDate } = passkey; + return { + id, + name, + date, + softwareEmojiId: softwareEmojiId?.toString(), + lastUsageDate, + }; +} diff --git a/src/api/gramjs/gramjsBuilders/index.ts b/src/api/gramjs/gramjsBuilders/index.ts index 34a0bac83..0f453b571 100644 --- a/src/api/gramjs/gramjsBuilders/index.ts +++ b/src/api/gramjs/gramjsBuilders/index.ts @@ -362,7 +362,7 @@ export function buildInputStory(story: ApiStory | ApiStorySkipped) { export function generateRandomTimestampedBigInt() { // 32 bits for timestamp, 32 bits are random const buffer = generateRandomBytes(8); - const timestampBuffer = Buffer.alloc(4); + const timestampBuffer = Buffer.allocUnsafe(4); timestampBuffer.writeUInt32LE(Math.floor(Date.now() / 1000), 0); buffer.set(timestampBuffer, 4); return readBigIntFromBuffer(buffer, true, true); diff --git a/src/api/gramjs/gramjsBuilders/passkeys.ts b/src/api/gramjs/gramjsBuilders/passkeys.ts new file mode 100644 index 000000000..1c5e065d7 --- /dev/null +++ b/src/api/gramjs/gramjsBuilders/passkeys.ts @@ -0,0 +1,31 @@ +import { Api as GramJs } from '../../../lib/gramjs'; + +import { base64UrlToBuffer, base64UrlToString } from '../../../util/encoding/base64'; + +export function buildInputPasskeyCredential( + credentialJson: PublicKeyCredentialJSON, +): GramJs.TypeInputPasskeyCredential { + let response: GramJs.TypeInputPasskeyResponse; + const clientData = base64UrlToString(credentialJson.response.clientDataJSON); + if (credentialJson.response.attestationObject) { + response = new GramJs.InputPasskeyResponseRegister({ + clientData: new GramJs.DataJSON({ data: clientData }), + attestationData: base64UrlToBuffer(credentialJson.response.attestationObject), + }); + } else { + const userHandle = base64UrlToString(credentialJson.response.userHandle); + + response = new GramJs.InputPasskeyResponseLogin({ + clientData: new GramJs.DataJSON({ data: clientData }), + authenticatorData: base64UrlToBuffer(credentialJson.response.authenticatorData), + signature: base64UrlToBuffer(credentialJson.response.signature), + userHandle, + }); + } + + return new GramJs.InputPasskeyCredentialPublicKey({ + id: credentialJson.id, + rawId: credentialJson.rawId, + response, + }); +} diff --git a/src/api/gramjs/helpers/misc.ts b/src/api/gramjs/helpers/misc.ts index 2c73da4d4..f4cb5db3e 100644 --- a/src/api/gramjs/helpers/misc.ts +++ b/src/api/gramjs/helpers/misc.ts @@ -36,6 +36,7 @@ const ERROR_KEYS: Record = { SRP_PASSWORD_CHANGED: 'ErrorPasswordChanged', CODE_INVALID: 'ErrorEmailCodeInvalid', PASSWORD_MISSING: 'ErrorPasswordMissing', + PASSKEY_CREDENTIAL_NOT_FOUND: 'ErrorPasskeyUnknown', }; export type MessageRepairContext = Pick; diff --git a/src/api/gramjs/methods/auth.ts b/src/api/gramjs/methods/auth.ts index 6b20a3242..93d756c4d 100644 --- a/src/api/gramjs/methods/auth.ts +++ b/src/api/gramjs/methods/auth.ts @@ -1,4 +1,7 @@ +import { PasskeyLoginRequestedError, UserAlreadyAuthorizedError } from '../../../lib/gramjs/errors'; + import type { + ApiPasskeyOption, ApiUpdateAuthorizationState, ApiUpdateAuthorizationStateType, ApiUser, @@ -19,6 +22,13 @@ export function onWebAuthTokenFailed() { }); } +export function onPasskeyOption(option: ApiPasskeyOption) { + sendApiUpdate({ + '@type': 'updatePasskeyOption', + option, + }); +} + export function onRequestPhoneNumber() { sendApiUpdate(buildAuthStateUpdate('authorizationStateWaitPhoneNumber')); @@ -75,11 +85,20 @@ export function onRequestQrCode(qrCode: { token: Buffer; expires: number }) { } export function onAuthError(err: Error) { - const { messageKey } = wrapError(err); + if (err instanceof UserAlreadyAuthorizedError) { + sendApiUpdate({ + '@type': 'updateUserAlreadyAuthorized', + userId: err.userId, + }); + return; + } + + const { messageKey, errorMessage } = wrapError(err); sendApiUpdate({ '@type': 'updateAuthorizationError', errorKey: messageKey, + errorCode: errorMessage, }); } @@ -151,3 +170,11 @@ export function restartAuthWithQr() { authController.reject(new Error('RESTART_AUTH_WITH_QR')); } + +export function restartAuthWithPasskey(credentialJson: PublicKeyCredentialJSON) { + if (!authController.reject) { + return; + } + + authController.reject(new PasskeyLoginRequestedError(credentialJson)); +} diff --git a/src/api/gramjs/methods/client.ts b/src/api/gramjs/methods/client.ts index 5456602b5..c1f36c4f2 100644 --- a/src/api/gramjs/methods/client.ts +++ b/src/api/gramjs/methods/client.ts @@ -54,8 +54,16 @@ import { updateChannelState, } from '../updates/updateManager'; import { - onAuthError, onAuthReady, onCurrentUserUpdate, onRequestCode, onRequestPassword, onRequestPhoneNumber, - onRequestQrCode, onRequestRegistration, onWebAuthTokenFailed, + onAuthError, + onAuthReady, + onCurrentUserUpdate, + onPasskeyOption, + onRequestCode, + onRequestPassword, + onRequestPhoneNumber, + onRequestQrCode, + onRequestRegistration, + onWebAuthTokenFailed, } from './auth'; import downloadMediaWithClient, { parseMediaUrl } from './media'; @@ -84,6 +92,7 @@ export async function init(initialArgs: ApiInitialArgs, onConnected?: NoneToVoid userAgent, platform, sessionData, isWebmSupported, maxBufferSize, webAuthToken, dcId, mockScenario, shouldForceHttpTransport, shouldAllowHttpTransport, shouldDebugExportedSenders, langCode, isTestServerRequested, accountIds, + hasPasskeySupport, } = initialArgs; const session = new sessions.CallbackSession(sessionData, onSessionUpdate); @@ -131,6 +140,7 @@ export async function init(initialArgs: ApiInitialArgs, onConnected?: NoneToVoid phoneCode: onRequestCode, password: onRequestPassword, firstAndLastNames: onRequestRegistration, + onPasskeyOption, qrCode: onRequestQrCode, onError: onAuthError, initialMethod: platform === 'iOS' || platform === 'Android' ? 'phoneNumber' : 'qrCode', @@ -139,6 +149,7 @@ export async function init(initialArgs: ApiInitialArgs, onConnected?: NoneToVoid webAuthTokenFailed: onWebAuthTokenFailed, mockScenario, accountIds, + hasPasskeySupport, }, onConnected); } catch (err: any) { // eslint-disable-next-line no-console diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 510275ceb..3611661f9 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -5,6 +5,7 @@ export { export { provideAuthPhoneNumber, provideAuthCode, provideAuthPassword, provideAuthRegistration, restartAuth, restartAuthWithQr, + restartAuthWithPasskey, } from './auth'; export { diff --git a/src/api/gramjs/methods/settings.ts b/src/api/gramjs/methods/settings.ts index 79d0c0b9c..c102bb0e4 100644 --- a/src/api/gramjs/methods/settings.ts +++ b/src/api/gramjs/methods/settings.ts @@ -8,6 +8,7 @@ import type { ApiInputPrivacyRules, ApiLanguage, ApiNotifyPeerType, + ApiPasskeyRegistrationOption, ApiPeerNotifySettings, ApiPhoto, ApiPrivacyKey, @@ -16,6 +17,7 @@ import type { import { ACCEPTABLE_USERNAME_ERRORS, + DEBUG, LANG_PACK, MUTE_INDEFINITE_TIMESTAMP, UNMUTE_TIMESTAMP, @@ -28,6 +30,7 @@ import { buildApiDisallowedGiftsSettings } from '../apiBuilders/gifts'; import { buildApiCountryList, buildApiLanguage, + buildApiPasskey, buildApiSession, buildApiTimezone, buildApiWallpaper, @@ -44,12 +47,14 @@ import { import { buildDisallowedGiftsSettings, buildInputChannel, - buildInputPeer, buildInputPhoto, + buildInputPeer, + buildInputPhoto, buildInputPrivacyKey, buildInputPrivacyRules, buildInputUser, DEFAULT_PRIMITIVES, } from '../gramjsBuilders'; +import { buildInputPasskeyCredential } from '../gramjsBuilders/passkeys'; import { addPhotoToLocalDb } from '../helpers/localDb'; import localDb from '../localDb'; import { getClient, invokeRequest, uploadFile } from './client'; @@ -766,3 +771,48 @@ export function reorderUsernames({ chatId, accessHash, usernames }: { order: usernames, })); } + +export async function fetchPasskeys() { + const result = await invokeRequest(new GramJs.account.GetPasskeys()); + if (!result) { + return undefined; + } + + return { + passkeys: result.passkeys.map(buildApiPasskey), + }; +} + +export async function initPasskeyRegistration() { + const result = await invokeRequest(new GramJs.account.InitPasskeyRegistration()); + if (!result) { + return undefined; + } + + try { + return JSON.parse(result.options.data) as ApiPasskeyRegistrationOption; + } catch (err: unknown) { + if (DEBUG) { + // eslint-disable-next-line no-console + console.warn('Failed to parse passkey registration options:', err); + } + } + return undefined; +} + +export async function registerPasskey(credentialJson: PublicKeyCredentialJSON) { + const result = await invokeRequest(new GramJs.account.RegisterPasskey({ + credential: buildInputPasskeyCredential(credentialJson), + })); + if (!result) { + return undefined; + } + + return buildApiPasskey(result); +} + +export function deletePasskey({ id }: { id: string }) { + return invokeRequest(new GramJs.account.DeletePasskey({ id }), { + shouldReturnTrue: true, + }); +} diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index a75c692b7..ff6cc5dd4 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -25,8 +25,17 @@ export interface ApiInitialArgs { langCode: string; isTestServerRequested?: boolean; accountIds?: string[]; + hasPasskeySupport?: boolean; } +export type ApiPasskeyOption = { + publicKey: PublicKeyCredentialRequestOptionsJSON; +}; + +export type ApiPasskeyRegistrationOption = { + publicKey: PublicKeyCredentialCreationOptionsJSON; +}; + export interface ApiOnProgress { ( progress: number, // Float between 0 and 1. @@ -276,6 +285,8 @@ export interface ApiAppConfig { verifyAgeMin?: number; typingDraftTtl: number; contactNoteLimit?: number; + arePasskeysAvailable: boolean; + passkeysMaxCount: number; } export interface ApiConfig { @@ -390,3 +401,11 @@ export interface ApiRestrictionReason { text: string; platform: string; } + +export interface ApiPasskey { + id: string; + name: string; + date: number; + softwareEmojiId?: string; + lastUsageDate?: number; +} diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index c87264adc..add51f3d7 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -39,6 +39,7 @@ import type { ApiEmojiInteraction, ApiError, ApiNotifyPeerType, + ApiPasskeyOption, ApiPeerNotifySettings, ApiSessionData, } from './misc'; @@ -85,6 +86,11 @@ export type ApiUpdateWebAuthTokenFailed = { '@type': 'updateWebAuthTokenFailed'; }; +export type ApiUpdatePasskeyOption = { + '@type': 'updatePasskeyOption'; + option: ApiPasskeyOption; +}; + export type ApiUpdateSession = { '@type': 'updateSession'; sessionData?: ApiSessionData; @@ -93,6 +99,12 @@ export type ApiUpdateSession = { export type ApiUpdateAuthorizationError = { '@type': 'updateAuthorizationError'; errorKey: RegularLangFnParameters; + errorCode?: string; +}; + +export type ApiUpdateUserAlreadyAuthorized = { + '@type': 'updateUserAlreadyAuthorized'; + userId: string; }; export type ApiUpdateConnectionState = { @@ -877,11 +889,11 @@ export type ApiUpdate = ( ApiUpdateChat | ApiUpdateChatInbox | ApiUpdateChatTypingStatus | ApiUpdateChatFullInfo | ApiUpdatePinnedChatIds | ApiUpdateChatMembers | ApiUpdateChatJoin | ApiUpdateChatLeave | ApiUpdateChatPinned | ApiUpdatePinnedMessageIds | ApiUpdateChatListType | ApiUpdateChatFolder | ApiUpdateChatFoldersOrder | ApiUpdateRecommendedChatFolders | - ApiUpdateNewMessage | ApiUpdateMessage | ApiUpdateThreadInfo | ApiUpdateCommonBoxMessages | + ApiUpdateNewMessage | ApiUpdateMessage | ApiUpdateThreadInfo | ApiUpdateCommonBoxMessages | ApiUpdatePasskeyOption | ApiUpdateDeleteMessages | ApiUpdateMessagePoll | ApiUpdateMessagePollVote | ApiUpdateDeleteHistory | ApiDeleteParticipantHistory | ApiUpdateMessageSendSucceeded | ApiUpdateMessageSendFailed | ApiUpdateServiceNotification | ApiDeleteContact | ApiUpdateUser | ApiUpdateUserStatus | - ApiUpdateUserFullInfo | ApiUpdateVideoProcessingPending | ApiUpdatePeerSettings | + ApiUpdateUserFullInfo | ApiUpdateVideoProcessingPending | ApiUpdatePeerSettings | ApiUpdateUserAlreadyAuthorized | ApiUpdateAvatar | ApiUpdateMessageImage | ApiUpdateDraftMessage | ApiUpdateScheduledMessageSendFailed | ApiUpdateError | ApiUpdateResetContacts | ApiUpdateStartEmojiInteraction | ApiUpdateFavoriteStickers | ApiUpdateStickerSet | ApiUpdateStickerSets | ApiUpdateStickerSetsOrder | diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index 1bf376b05..0d8a85a0c 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -175,6 +175,7 @@ "LoginQRHelp2" = "Go to **Settings** > **Devices** > **Link Desktop Device**"; "LoginQRHelp3" = "Point your phone at this screen to confirm login"; "LoginQRCancel" = "Log in by phone number"; +"LoginPasskey" = "Log in with Passkey"; "YourName" = "Your Name"; "LoginRegisterDesc" = "Enter your name and add a profile photo."; "LoginRegisterFirstNamePlaceholder" = "First Name"; @@ -519,8 +520,6 @@ "Users_one" = "{count} user"; "Users_other" = "{count} users"; "PrivacySettingsWebSessions" = "Active Websites"; -"PasswordOn" = "On"; -"PasswordOff" = "Off"; "PrivacyTitle" = "Privacy"; "PrivacyPhoneTitle" = "Who can see my phone number?"; "LastSeenTitle" = "Who can see your Last Seen time?"; @@ -717,6 +716,7 @@ "ErrorNewSaltInvalid" = "Error validating password, please try again"; "ErrorPasswordChanged" = "Password has been changed, please try again"; "ErrorPasswordMissing" = "You must set 2FA password to use this feature"; +"ErrorPasskeyUnknown" = "This passkey is not assigned to any account"; "ErrorUnspecified" = "Error"; "NoStickers" = "No stickers yet"; "ClearRecentEmoji" = "Clear recent emoji?"; @@ -2406,6 +2406,29 @@ "StarGiftUpgradeCostModalTitle" = "Upgrade Cost"; "StarGiftUpgradeCostHint" = "Users who upgrade their gifts first get collectibles with shorter numbers."; "StarGiftPriceDecreaseTimer" = "Price decreases in {timer}"; +"SettingsItemPrivacyPasskeys" = "Passkeys"; +"SettingsItemPrivacyOn" = "Enabled"; +"SettingsItemPrivacyOff" = "Disabled"; +"SettingsPasskeyUsedAt" = "Last used {date}"; +"SettingsPasskeyTitle" = "Passkeys"; +"SettingsPasskeyInfo" = "Manage your passkey, stored safely in the cloud service you choose."; +"SettingsPasskeyFallbackTitle" = "Passkey"; +"SettingsPasskeysFooter" = "Your passkeys are stored securely in your password manager. {link}"; +"SettingsPasskeysFooterLink" = "Learn more >"; +"SettingsPasskeysCreate" = "Create a Passkey"; +"PasskeyModalTitle" = "Protect your account"; +"PasskeyModalDescription" = "Log in safely and keep your account secure."; +"PasskeyModalFeature1Title" = "Create a Passkey"; +"PasskeyModalFeature1Description" = "Make a passkey to sign in easily and safely."; +"PasskeyModalFeature2Title" = "Log in with face recognition"; +"PasskeyModalFeature2Description" = "Use your face, fingerprint, or passcode to sign in."; +"PasskeyModalFeature3Title" = "Store Passkey securely"; +"PasskeyModalFeature3Description" = "Your passkey is stored safely in the cloud service you choose."; +"PasskeyModalButtonText" = "Create Passkey"; +"PasskeyDeleteTitle" = "Delete Passkey"; +"PasskeyDeleteText" = "Once deleted, this passkey can't be used to log in.\nDon't forget to remove it from your password manager."; +"PasskeyCreateError" = "Failed to create passkey. Please try again."; +"PasskeyLoginError" = "Failed to log in with a passkey"; "UnconfirmedAuthDeniedTitle" = "New Login Prevented"; "UnconfirmedAuthDeniedMessage" = "We have terminated the login attempt from {location}."; "UnconfirmedAuthTitle" = "Someone just got access to your messages!"; diff --git a/src/assets/localization/initialKeys.ts b/src/assets/localization/initialKeys.ts index 0868f71cd..15e37b374 100644 --- a/src/assets/localization/initialKeys.ts +++ b/src/assets/localization/initialKeys.ts @@ -16,6 +16,7 @@ const INITIAL_KEYS: LangKey[] = [ 'LoginQRHelp2', 'LoginQRHelp3', 'LoginQRCancel', + 'LoginPasskey', 'YourName', 'LoginRegisterDesc', 'LoginRegisterFirstNamePlaceholder', diff --git a/src/assets/localization/initialStrings.ts b/src/assets/localization/initialStrings.ts index 8ee3c2fbc..6fad85819 100644 --- a/src/assets/localization/initialStrings.ts +++ b/src/assets/localization/initialStrings.ts @@ -18,7 +18,8 @@ export default { "LoginQRHelp1": "Open Telegram on your phone", "LoginQRHelp2": "Go to **Settings** > **Devices** > **Link Desktop Device**", "LoginQRHelp3": "Point your phone at this screen to confirm login", - "LoginQRCancel": "Log in by phone Number", + "LoginQRCancel": "Log in by phone number", + "LoginPasskey": "Log in with Passkey", "YourName": "Your Name", "LoginRegisterDesc": "Enter your name and add a profile photo.", "LoginRegisterFirstNamePlaceholder": "First Name", @@ -26,12 +27,12 @@ export default { "LoginSelectCountryTitle": "Country", "CountryNone": "Country not found", "PleaseEnterPassword": "Enter your new password", - "ErrorPhoneNumberInvalid": "Invalid phone number, please try again.", - "ErrorCodeInvalid": "Invalid code, please try again.", - "ErrorIncorrectPassword": "Invalid password, please try again.", - "ErrorPasswordFlood": "Too many attempts, please try again later.", - "ErrorPhoneBanned": "This phone number is banned.", - "ErrorFloodTime": "Too many attempts, please try again in {time}.", + "ErrorPhoneNumberInvalid": "Invalid phone number, please try again", + "ErrorCodeInvalid": "Invalid code, please try again", + "ErrorIncorrectPassword": "Invalid password, please try again", + "ErrorPasswordFlood": "Too many attempts, please try again later", + "ErrorPhoneBanned": "This phone number is banned", + "ErrorFloodTime": "Too many attempts, please try again in {time}", "ErrorUnexpected": "Unexpected error", "ErrorUnexpectedMessage": "Unexpected error: {error}" } as Record; diff --git a/src/assets/tgs-previews/DuckNothingFound.svg b/src/assets/tgs-previews/DuckNothingFound.svg index 5288391e5..b64940881 100644 --- a/src/assets/tgs-previews/DuckNothingFound.svg +++ b/src/assets/tgs-previews/DuckNothingFound.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/assets/tgs-previews/Search.svg b/src/assets/tgs-previews/Search.svg index 8156ca297..a0e140bbe 100644 --- a/src/assets/tgs-previews/Search.svg +++ b/src/assets/tgs-previews/Search.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/assets/tgs-previews/settings/Passkeys.svg b/src/assets/tgs-previews/settings/Passkeys.svg new file mode 100644 index 000000000..8d8c9b422 --- /dev/null +++ b/src/assets/tgs-previews/settings/Passkeys.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/tgs/settings/Passkeys.tgs b/src/assets/tgs/settings/Passkeys.tgs new file mode 100644 index 000000000..62e11b254 Binary files /dev/null and b/src/assets/tgs/settings/Passkeys.tgs differ diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index 6f5470f5c..4188f1b96 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -5,7 +5,6 @@ export { default as ForwardRecipientPicker } from '../components/main/ForwardRec export { default as DraftRecipientPicker } from '../components/main/DraftRecipientPicker'; export { default as AttachBotRecipientPicker } from '../components/main/AttachBotRecipientPicker'; export { default as Dialogs } from '../components/main/Dialogs'; -export { default as Notifications } from '../components/main/Notifications'; export { default as SafeLinkModal } from '../components/main/SafeLinkModal'; export { default as MapModal } from '../components/modals/map/MapModal'; export { default as UrlAuthModal } from '../components/modals/urlAuth/UrlAuthModal'; @@ -27,6 +26,7 @@ export { default as DeleteAccountModal } from '../components/modals/deleteAccoun export { default as AgeVerificationModal } from '../components/modals/ageVerification/AgeVerificationModal'; export { default as ChatlistModal } from '../components/modals/chatlist/ChatlistModal'; export { default as ChatInviteModal } from '../components/modals/chatInvite/ChatInviteModal'; +export { default as PasskeyModal } from '../components/modals/passkey/PasskeyModal'; export { default as BirthdaySetupModal } from '../components/modals/birthday/BirthdaySetupModal'; export { default as AboutAdsModal } from '../components/modals/aboutAds/AboutAdsModal'; diff --git a/src/components/App.tsx b/src/components/App.tsx index f6275a85c..b72012f7b 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,4 +1,3 @@ -import type { FC } from '../lib/teact/teact'; import { useEffect, useLayoutEffect } from '../lib/teact/teact'; import { withGlobal } from '../global'; @@ -22,11 +21,12 @@ import { updateSizes } from '../util/windowSize'; import useTauriDrag from '../hooks/tauri/useTauriDrag'; import useAppLayout from '../hooks/useAppLayout'; import usePrevious from '../hooks/usePrevious'; -import { useSignalEffect } from '../hooks/useSignalEffect.ts'; -import { getIsInBackground } from '../hooks/window/useBackgroundMode.ts'; +import { useSignalEffect } from '../hooks/useSignalEffect'; +import { getIsInBackground } from '../hooks/window/useBackgroundMode'; // import Test from './test/TestLocale'; import Auth from './auth/Auth'; +import Notifications from './common/Notifications'; import UiLoader from './common/UiLoader'; import AppInactive from './main/AppInactive'; import LockScreen from './main/LockScreen.async'; @@ -36,7 +36,7 @@ import Transition from './ui/Transition'; import styles from './App.module.scss'; type StateProps = { - authState: GlobalState['authState']; + authState: GlobalState['auth']['state']; isScreenLocked?: boolean; hasPasscode?: boolean; inactiveReason?: 'auth' | 'otherClient'; @@ -57,7 +57,7 @@ const TRANSITION_RENDER_COUNT = Object.keys(AppScreens).length / 2; const ACTIVE_PAGE_TITLE = IS_TAURI ? PAGE_TITLE_TAURI : PAGE_TITLE; const INACTIVE_PAGE_TITLE = `${ACTIVE_PAGE_TITLE} ${INACTIVE_MARKER}`; -const App: FC = ({ +const App = ({ authState, isScreenLocked, hasPasscode, @@ -66,7 +66,7 @@ const App: FC = ({ isTestServer, theme, actionMessageBg, -}) => { +}: StateProps) => { const { isMobile } = useAppLayout(); const isMobileOs = PLATFORM_ENV === 'iOS' || PLATFORM_ENV === 'Android'; @@ -257,18 +257,20 @@ const App: FC = ({ {renderContent} {activeKey === AppScreens.auth && isTestServer &&
Test server
} + ); }; export default withGlobal( (global): Complete => { + const { state: authState, hasWebAuthTokenFailed, hasWebAuthTokenPasswordRequired } = global.auth; return { - authState: global.authState, + authState, isScreenLocked: global.passcode?.isScreenLocked, hasPasscode: global.passcode?.hasPasscode, inactiveReason: selectTabState(global).inactiveReason, - hasWebAuthTokenFailed: global.hasWebAuthTokenFailed || global.hasWebAuthTokenPasswordRequired, + hasWebAuthTokenFailed: hasWebAuthTokenFailed || hasWebAuthTokenPasswordRequired, theme: selectTheme(global), isTestServer: global.config?.isTestServer, actionMessageBg: selectActionMessageBg(global), diff --git a/src/components/auth/Auth.tsx b/src/components/auth/Auth.tsx index 0df374b8b..d3eb1ec95 100644 --- a/src/components/auth/Auth.tsx +++ b/src/components/auth/Auth.tsx @@ -1,6 +1,5 @@ import '../../global/actions/initial'; -import type { FC } from '../../lib/teact/teact'; import { memo } from '../../lib/teact/teact'; import { getActions, withGlobal } from '../../global'; @@ -21,11 +20,13 @@ import AuthRegister from './AuthRegister.async'; import './Auth.scss'; -type StateProps = Pick; +type StateProps = { + authState: GlobalState['auth']['state']; +}; -const Auth: FC = ({ +const Auth = ({ authState, -}) => { +}: StateProps) => { const { returnToAuthPhoneNumber, goToAuthQrCode, } = getActions(); @@ -101,7 +102,7 @@ const Auth: FC = ({ export default memo(withGlobal( (global): Complete => { return { - authState: global.authState, + authState: global.auth.state, }; }, )(Auth)); diff --git a/src/components/auth/AuthCode.tsx b/src/components/auth/AuthCode.tsx index 67cdb6f64..8be88cc9c 100644 --- a/src/components/auth/AuthCode.tsx +++ b/src/components/auth/AuthCode.tsx @@ -1,5 +1,3 @@ -import type { FormEvent } from 'react'; -import type { FC } from '../../lib/teact/teact'; import { memo, useCallback, useEffect, useRef, useState, } from '../../lib/teact/teact'; @@ -18,22 +16,23 @@ import TrackingMonkey from '../common/TrackingMonkey'; import InputText from '../ui/InputText'; import Loading from '../ui/Loading'; -type StateProps = Pick; +type StateProps = { + auth: GlobalState['auth']; +}; const CODE_LENGTH = 5; -const AuthCode: FC = ({ - authPhoneNumber, - authIsCodeViaApp, - authIsLoading, - authErrorKey, -}) => { +const AuthCode = ({ + auth, +}: StateProps) => { const { setAuthCode, returnToAuthPhoneNumber, clearAuthErrorKey, } = getActions(); + const { phoneNumber, isCodeViaApp, isLoading, errorKey } = auth; + const lang = useLang(); const inputRef = useRef(); @@ -52,8 +51,8 @@ const AuthCode: FC = ({ onBack: returnToAuthPhoneNumber, }); - const onCodeChange = useCallback((e: FormEvent) => { - if (authErrorKey) { + const onCodeChange = useCallback((e: React.FormEvent) => { + if (errorKey) { clearAuthErrorKey(); } @@ -81,7 +80,7 @@ const AuthCode: FC = ({ if (target.value.length === CODE_LENGTH) { setAuthCode({ code: target.value }); } - }, [authErrorKey, code, isTracking, setAuthCode]); + }, [errorKey, code, isTracking]); function handleReturnToAuthPhoneNumber() { returnToAuthPhoneNumber(); @@ -97,7 +96,7 @@ const AuthCode: FC = ({ trackingDirection={trackingDirection} />

- {authPhoneNumber} + {phoneNumber}
= ({

- {lang(authIsCodeViaApp ? 'SentAppCode' : 'LoginJustSentSms', undefined, { + {lang(isCodeViaApp ? 'SentAppCode' : 'LoginJustSentSms', undefined, { withNodes: true, withMarkdown: true, })} @@ -121,11 +120,11 @@ const AuthCode: FC = ({ label={lang('Code')} onInput={onCodeChange} value={code} - error={authErrorKey && lang.withRegular(authErrorKey)} + error={errorKey && lang.withRegular(errorKey)} autoComplete="off" inputMode="numeric" /> - {authIsLoading && } + {isLoading && } ); @@ -133,6 +132,6 @@ const AuthCode: FC = ({ export default memo(withGlobal( (global): Complete => ( - pick(global, ['authPhoneNumber', 'authIsCodeViaApp', 'authIsLoading', 'authErrorKey']) as Complete + pick(global, ['auth']) ), )(AuthCode)); diff --git a/src/components/auth/AuthPassword.tsx b/src/components/auth/AuthPassword.tsx index da7cb3702..65bc40101 100644 --- a/src/components/auth/AuthPassword.tsx +++ b/src/components/auth/AuthPassword.tsx @@ -1,4 +1,3 @@ -import type { FC } from '../../lib/teact/teact'; import { memo, useCallback, useState } from '../../lib/teact/teact'; import { getActions, withGlobal } from '../../global'; @@ -11,12 +10,15 @@ import useLang from '../../hooks/useLang'; import PasswordForm from '../common/PasswordForm'; import MonkeyPassword from '../common/PasswordMonkey'; -type StateProps = Pick; +type StateProps = { + auth: GlobalState['auth']; +}; -const AuthPassword: FC = ({ - authIsLoading, authErrorKey, authHint, -}) => { +const AuthPassword = ({ + auth, +}: StateProps) => { const { setAuthPassword, clearAuthErrorKey } = getActions(); + const { isLoading, errorKey, hint } = auth; const lang = useLang(); const [showPassword, setShowPassword] = useState(false); @@ -37,9 +39,9 @@ const AuthPassword: FC = ({

{lang('LoginEnterPasswordDescription')}

= ({ export default memo(withGlobal( (global): Complete => ( - pick(global, ['authIsLoading', 'authErrorKey', 'authHint']) as Complete + pick(global, ['auth']) ), )(AuthPassword)); diff --git a/src/components/auth/AuthPhoneNumber.tsx b/src/components/auth/AuthPhoneNumber.tsx index 0af688101..e9d0be348 100644 --- a/src/components/auth/AuthPhoneNumber.tsx +++ b/src/components/auth/AuthPhoneNumber.tsx @@ -1,6 +1,3 @@ -import type { ChangeEvent } from 'react'; -import type { FC } from '../../lib/teact/teact'; -import type React from '../../lib/teact/teact'; import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState, } from '../../lib/teact/teact'; @@ -13,7 +10,6 @@ import { requestMeasure } from '../../lib/fasterdom/fasterdom'; import { IS_SAFARI, IS_TOUCH_ENV } from '../../util/browser/windowEnvironment'; import { preloadImage } from '../../util/files'; import preloadFonts from '../../util/fonts'; -import { pick } from '../../util/iteratees'; import { getAccountSlotUrl } from '../../util/multiaccount'; import { oldSetLanguage } from '../../util/oldLangProvider'; import { formatPhoneNumber, getCountryCodeByIso, getCountryFromPhoneNumber } from '../../util/phoneNumber'; @@ -34,12 +30,9 @@ import CountryCodeInput from './CountryCodeInput'; import monkeyPath from '../../assets/monkey.svg'; -type StateProps = Pick & { +type StateProps = { + auth: GlobalState['auth']; + connectionState: GlobalState['connectionState']; language?: string; phoneCodeList: ApiCountryCode[]; isTestServer?: boolean; @@ -49,19 +42,13 @@ const MIN_NUMBER_LENGTH = 7; let isPreloadInitiated = false; -const AuthPhoneNumber: FC = ({ +const AuthPhoneNumber = ({ + auth, connectionState, - authState, - authPhoneNumber, - authIsLoading, - authIsLoadingQrCode, - authErrorKey, - authRememberMe, - authNearestCountry, phoneCodeList, language, isTestServer, -}) => { +}: StateProps) => { const { setAuthPhoneNumber, setAuthRememberMe, @@ -70,8 +57,20 @@ const AuthPhoneNumber: FC = ({ clearAuthErrorKey, goToAuthQrCode, setSharedSettingOption, + loginWithPasskey, } = getActions(); + const { + state, + phoneNumber: authPhoneNumber, + nearestCountry, + isLoading: authIsLoading, + errorKey, + rememberMe, + isLoadingQrCode, + passkeyOption, + } = auth; + const lang = useLang(); const inputRef = useRef(); const suggestedLanguage = getSuggestedLanguage(); @@ -105,10 +104,10 @@ const AuthPhoneNumber: FC = ({ }, [country]); useEffect(() => { - if (isConnected && !authNearestCountry) { + if (isConnected && !nearestCountry) { loadNearestCountry(); } - }, [isConnected, authNearestCountry]); + }, [isConnected, nearestCountry]); useEffect(() => { if (isConnected) { @@ -117,10 +116,10 @@ const AuthPhoneNumber: FC = ({ }, [isConnected, language]); useEffect(() => { - if (authNearestCountry && phoneCodeList && !country && !isTouched) { - setCountry(getCountryCodeByIso(phoneCodeList, authNearestCountry)); + if (nearestCountry && phoneCodeList && !country && !isTouched) { + setCountry(getCountryCodeByIso(phoneCodeList, nearestCountry)); } - }, [country, authNearestCountry, isTouched, phoneCodeList]); + }, [country, nearestCountry, isTouched, phoneCodeList]); const parseFullNumber = useLastCallback((newFullNumber: string) => { if (!newFullNumber.length) { @@ -181,8 +180,8 @@ const AuthPhoneNumber: FC = ({ setPhoneNumber(''); }); - const handlePhoneNumberChange = useLastCallback((e: ChangeEvent) => { - if (authErrorKey) { + const handlePhoneNumberChange = useLastCallback((e: React.ChangeEvent) => { + if (errorKey) { clearAuthErrorKey(); } @@ -209,7 +208,7 @@ const AuthPhoneNumber: FC = ({ parseFullNumber(shouldFixSafariAutoComplete ? `${country.countryCode} ${value}` : value); }); - const handleKeepSessionChange = useLastCallback((e: ChangeEvent) => { + const handleKeepSessionChange = useLastCallback((e: React.ChangeEvent) => { setAuthRememberMe({ value: e.target.checked }); }); @@ -235,7 +234,11 @@ const AuthPhoneNumber: FC = ({ goToAuthQrCode(); }); - const isAuthReady = authState === 'authorizationStateWaitPhoneNumber'; + const handleLoginWithPasskey = useLastCallback(() => { + loginWithPasskey(); + }); + + const isAuthReady = state === 'authorizationStateWaitPhoneNumber'; return (
@@ -257,7 +260,7 @@ const AuthPhoneNumber: FC = ({ = ({ id="sign-in-phone-number" label={lang('LoginPhonePlaceholder')} value={fullNumber} - error={authErrorKey && lang.withRegular(authErrorKey)} + error={errorKey && lang.withRegular(errorKey)} inputMode="tel" onChange={handlePhoneNumberChange} onPaste={IS_SAFARI ? handlePaste : undefined} @@ -273,7 +276,7 @@ const AuthPhoneNumber: FC = ({ {canSubmit && ( @@ -295,12 +298,17 @@ const AuthPhoneNumber: FC = ({ className="auth-button" isText ripple - isLoading={authIsLoadingQrCode} + isLoading={isLoadingQrCode} onClick={handleGoToAuthQrCode} > {lang('LoginQRLogin')} )} + {passkeyOption && ( + + )} {suggestedLanguage && suggestedLanguage !== language && continueText && ( )} + {passkeyOption && ( + + )} {suggestedLanguage && suggestedLanguage !== language && continueText && ( + )}
@@ -87,6 +87,6 @@ const AuthRegister: FC = ({ export default memo(withGlobal( (global): Complete => ( - pick(global, ['authIsLoading', 'authErrorKey']) as Complete + pick(global, ['auth']) ), )(AuthRegister)); diff --git a/src/components/main/Notifications.tsx b/src/components/common/Notifications.tsx similarity index 86% rename from src/components/main/Notifications.tsx rename to src/components/common/Notifications.tsx index 871786b94..51fc0579e 100644 --- a/src/components/main/Notifications.tsx +++ b/src/components/common/Notifications.tsx @@ -1,4 +1,3 @@ -import type { FC } from '../../lib/teact/teact'; import { memo } from '../../lib/teact/teact'; import { withGlobal } from '../../global'; @@ -13,7 +12,7 @@ type StateProps = { notifications: ApiNotification[]; }; -const Notifications: FC = ({ notifications }) => { +const Notifications = ({ notifications }: StateProps) => { if (!notifications.length) { return undefined; } diff --git a/src/components/common/helpers/animatedAssets.ts b/src/components/common/helpers/animatedAssets.ts index b49914915..1dc41eedd 100644 --- a/src/components/common/helpers/animatedAssets.ts +++ b/src/components/common/helpers/animatedAssets.ts @@ -33,15 +33,18 @@ import FoldersAll from '../../../assets/tgs/settings/FoldersAll.tgs'; import FoldersNew from '../../../assets/tgs/settings/FoldersNew.tgs'; import FoldersShare from '../../../assets/tgs/settings/FoldersShare.tgs'; import Lock from '../../../assets/tgs/settings/Lock.tgs'; +import Passkeys from '../../../assets/tgs/settings/Passkeys.tgs'; import StarReaction from '../../../assets/tgs/stars/StarReaction.tgs'; import StarReactionEffect from '../../../assets/tgs/stars/StarReactionEffect.tgs'; import Unlock from '../../../assets/tgs/Unlock.tgs'; import DuckNothingFoundPreview from '../../../assets/tgs-previews/DuckNothingFound.svg'; import SearchPreview from '../../../assets/tgs-previews/Search.svg'; +import PasskeysPreview from '../../../assets/tgs-previews/settings/Passkeys.svg'; export const LOCAL_TGS_PREVIEW_URLS = { DuckNothingFound: DuckNothingFoundPreview, Search: SearchPreview, + Passkeys: PasskeysPreview, }; export const LOCAL_TGS_URLS = { @@ -82,5 +85,6 @@ export const LOCAL_TGS_URLS = { Diamond, Search, DuckNothingFound, + Passkeys, DuckCake, }; diff --git a/src/components/left/LeftColumn.tsx b/src/components/left/LeftColumn.tsx index db7950ce2..c99dfdc9c 100644 --- a/src/components/left/LeftColumn.tsx +++ b/src/components/left/LeftColumn.tsx @@ -218,6 +218,7 @@ function LeftColumn({ case SettingsScreens.PasscodeDisabled: case SettingsScreens.PasscodeEnabled: case SettingsScreens.PasscodeCongratulations: + case SettingsScreens.Passkeys: openSettingsScreen({ screen: SettingsScreens.Privacy }); return; diff --git a/src/components/left/settings/Settings.tsx b/src/components/left/settings/Settings.tsx index bab0469a3..186ad4b4b 100644 --- a/src/components/left/settings/Settings.tsx +++ b/src/components/left/settings/Settings.tsx @@ -31,6 +31,7 @@ import SettingsHeader from './SettingsHeader'; import SettingsLanguage from './SettingsLanguage'; import SettingsMain from './SettingsMain'; import SettingsNotifications from './SettingsNotifications'; +import SettingsPasskeys from './SettingsPasskeys'; import SettingsPerformance from './SettingsPerformance'; import SettingsPrivacy from './SettingsPrivacy'; import SettingsPrivacyBlockedUsers from './SettingsPrivacyBlockedUsers'; @@ -85,6 +86,7 @@ const FOLDERS_SCREENS = [ const PRIVACY_SCREENS = [ SettingsScreens.PrivacyBlockedUsers, SettingsScreens.ActiveWebsites, + SettingsScreens.Passkeys, ]; const PRIVACY_PHONE_NUMBER_SCREENS = [ @@ -486,6 +488,14 @@ const Settings: FC = ({ /> ); + case SettingsScreens.Passkeys: + return ( + + ); + default: return undefined; } diff --git a/src/components/left/settings/SettingsHeader.tsx b/src/components/left/settings/SettingsHeader.tsx index 5b9e736b5..b654f2ce3 100644 --- a/src/components/left/settings/SettingsHeader.tsx +++ b/src/components/left/settings/SettingsHeader.tsx @@ -253,6 +253,10 @@ const SettingsHeader: FC = ({ )} ); + + case SettingsScreens.Passkeys: + return

{lang('SettingsPasskeyTitle')}

; + default: return (
diff --git a/src/components/left/settings/SettingsPasskeys.module.scss b/src/components/left/settings/SettingsPasskeys.module.scss new file mode 100644 index 000000000..3aa63c577 --- /dev/null +++ b/src/components/left/settings/SettingsPasskeys.module.scss @@ -0,0 +1,8 @@ +.icon { + --custom-emoji-size: 2rem; +} + +.fallbackIcon { + padding-inline: 0.25rem; + font-size: 1.5rem; +} diff --git a/src/components/left/settings/SettingsPasskeys.tsx b/src/components/left/settings/SettingsPasskeys.tsx new file mode 100644 index 000000000..d6cbf2285 --- /dev/null +++ b/src/components/left/settings/SettingsPasskeys.tsx @@ -0,0 +1,180 @@ +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 { 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(); + + 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 ( + { + setDeleteModalId(id); + }, + }]} + leftElement={softwareEmojiId ? ( + + ) : ( + + )} + > +
+ {formatPastDatetime(lang, date)} + {name || lang('SettingsPasskeyFallbackTitle')} + {Boolean(lastUsageDate) && ( + + {lang('SettingsPasskeyUsedAt', { + date: formatPastDatetime(lang, lastUsageDate), + })} + + )} +
+
+ ); + } + + return ( +
+
+ + +

+ {lang('SettingsPasskeyInfo')} +

+
+
+ {passkeys?.map(renderPasskey)} + {canAddPasskey && ( + + )} +

+ {lang('SettingsPasskeysFooter', { + link: {lang('SettingsPasskeysFooterLink')}, + }, { withNodes: true })} +

+
+ setDeleteModalId(undefined)} + /> +
+ ); +}; + +export default memo(withGlobal( + (global): Complete => { + return { + passkeys: global.settings.passkeys, + maxPasskeysCount: global.appConfig.passkeysMaxCount, + }; + }, +)(SettingsPasskeys)); diff --git a/src/components/left/settings/SettingsPrivacy.tsx b/src/components/left/settings/SettingsPrivacy.tsx index 9edbd7fbd..8644b2f3a 100644 --- a/src/components/left/settings/SettingsPrivacy.tsx +++ b/src/components/left/settings/SettingsPrivacy.tsx @@ -1,5 +1,4 @@ -import type { FC } from '../../../lib/teact/teact'; -import { memo, useCallback, useEffect, useMemo } from '../../../lib/teact/teact'; +import { memo, useEffect, useMemo } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; import type { ApiPrivacySettings } from '../../../api/types'; @@ -47,17 +46,21 @@ type StateProps = { needAgeVideoVerification?: boolean; privacy: GlobalState['settings']['privacy']; accountDaysTtl?: number; + passkeyCount?: number; + arePasskeysAvailable?: boolean; }; const DAYS_PER_MONTH = 30; -const SettingsPrivacy: FC = ({ +const SettingsPrivacy = ({ isActive, isCurrentUserPremium, hasPassword, hasPasscode, blockedCount, webAuthCount, + passkeyCount, + arePasskeysAvailable, isSensitiveEnabled, canChangeSensitive, canDisplayAutoarchiveSetting, @@ -71,7 +74,7 @@ const SettingsPrivacy: FC = ({ isCurrentUserFrozen, accountDaysTtl, onReset, -}) => { +}: OwnProps & StateProps) => { const { openDeleteAccountModal, loadPrivacySettings, @@ -84,6 +87,8 @@ const SettingsPrivacy: FC = ({ openSettingsScreen, loadAccountDaysTtl, openAgeVerificationModal, + loadPasskeys, + openPasskeyModal, } = getActions(); useEffect(() => { @@ -91,6 +96,7 @@ const SettingsPrivacy: FC = ({ loadBlockedUsers(); loadPrivacySettings({}); loadWebAuthorizations(); + loadPasskeys(); } }, [isCurrentUserFrozen]); @@ -99,7 +105,7 @@ const SettingsPrivacy: FC = ({ loadGlobalPrivacySettings(); loadAccountDaysTtl(); } - }, [isActive, isCurrentUserFrozen, loadGlobalPrivacySettings]); + }, [isActive, isCurrentUserFrozen]); const oldLang = useOldLang(); const lang = useLang(); @@ -109,31 +115,41 @@ const SettingsPrivacy: FC = ({ onBack: onReset, }); - const handleArchiveAndMuteChange = useCallback((isEnabled: boolean) => { + const handleArchiveAndMuteChange = useLastCallback((isEnabled: boolean) => { updateGlobalPrivacySettings({ shouldArchiveAndMuteNewNonContact: isEnabled, }); - }, [updateGlobalPrivacySettings]); + }); - const handleChatInTitleChange = useCallback((isChecked: boolean) => { + const handleChatInTitleChange = useLastCallback((isChecked: boolean) => { setSharedSettingOption({ canDisplayChatInTitle: isChecked, }); - }, []); + }); - const handleUpdateContentSettings = useCallback((isChecked: boolean) => { + const handleUpdateContentSettings = useLastCallback((isChecked: boolean) => { updateContentSettings({ isSensitiveEnabled: isChecked }); - }, [updateContentSettings]); + }); - const handleAgeVerification = useCallback(() => { + const handleAgeVerification = useLastCallback(() => { openAgeVerificationModal(); - }, [openAgeVerificationModal]); + }); const handleOpenDeleteAccountModal = useLastCallback(() => { if (!accountDaysTtl) return; openDeleteAccountModal({ days: accountDaysTtl }); }); + const handleOpenPasskeys = useLastCallback(() => { + if (!arePasskeysAvailable || passkeyCount === undefined) return; + if (passkeyCount === 0) { + openPasskeyModal(); + return; + } + + openSettingsScreen({ screen: SettingsScreens.Passkeys }); + }); + const dayOption = useMemo(() => { if (!accountDaysTtl) return undefined; return getClosestEntry(ACCOUNT_TTL_OPTIONS, accountDaysTtl / DAYS_PER_MONTH).toString(); @@ -192,7 +208,7 @@ const SettingsPrivacy: FC = ({ {canSetPasscode && ( openSettingsScreen({ @@ -202,13 +218,13 @@ const SettingsPrivacy: FC = ({
{oldLang('Passcode')} - {oldLang(hasPasscode ? 'PasswordOn' : 'PasswordOff')} + {lang(hasPasscode ? 'SettingsItemPrivacyOn' : 'SettingsItemPrivacyOff')}
)} openSettingsScreen({ @@ -218,10 +234,25 @@ const SettingsPrivacy: FC = ({
{oldLang('TwoStepVerification')} - {oldLang(hasPassword ? 'PasswordOn' : 'PasswordOff')} + {lang(hasPassword ? 'SettingsItemPrivacyOn' : 'SettingsItemPrivacyOff')}
+ {arePasskeysAvailable && ( + +
+ {lang('SettingsItemPrivacyPasskeys')} + + {lang(passkeyCount === undefined ? 'Loading' + : passkeyCount > 0 ? 'SettingsItemPrivacyOn' : 'SettingsItemPrivacyOff')} + +
+
+ )} {webAuthCount > 0 && ( ( }, privacy, accountDaysTtl, + passkeys, }, blocked, passcode: { @@ -501,6 +533,8 @@ export default memo(withGlobal( canSetPasscode: selectCanSetPasscode(global), isCurrentUserFrozen, accountDaysTtl, + passkeyCount: passkeys?.length, + arePasskeysAvailable: appConfig.arePasskeysAvailable, }; }, )(SettingsPrivacy)); diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index a3b02bbe7..af39f8eb1 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -85,7 +85,6 @@ import ForwardRecipientPicker from './ForwardRecipientPicker.async'; import GameModal from './GameModal'; import HistoryCalendar from './HistoryCalendar.async'; import NewContactModal from './NewContactModal.async'; -import Notifications from './Notifications.async'; import PremiumLimitReachedModal from './premium/common/PremiumLimitReachedModal.async'; import GiveawayModal from './premium/GiveawayModal.async'; import PremiumMainModal from './premium/PremiumMainModal.async'; @@ -110,7 +109,6 @@ type StateProps = { isMediaViewerOpen: boolean; isStoryViewerOpen: boolean; isForwardModalOpen: boolean; - hasNotifications: boolean; hasDialogs: boolean; safeLinkModalUrl?: string; isHistoryCalendarOpen: boolean; @@ -163,7 +161,6 @@ const Main = ({ isMediaViewerOpen, isStoryViewerOpen, isForwardModalOpen, - hasNotifications, hasDialogs, activeGroupCallId, safeLinkModalUrl, @@ -564,7 +561,6 @@ const Main = ({ - @@ -631,7 +627,6 @@ export default memo(withGlobal( openedGame, isLeftColumnShown, historyCalendarSelectedAt, - notifications, dialogs, newContact, ratingPhoneCall, @@ -665,7 +660,6 @@ export default memo(withGlobal( isStoryViewerOpen: selectIsStoryViewerOpen(global), isForwardModalOpen: selectIsForwardModalOpen(global), isReactionPickerOpen: selectIsReactionPickerOpen(global), - hasNotifications: Boolean(notifications.length), hasDialogs: Boolean(dialogs.length), safeLinkModalUrl, isHistoryCalendarOpen: Boolean(historyCalendarSelectedAt), diff --git a/src/components/main/Notifications.async.tsx b/src/components/main/Notifications.async.tsx deleted file mode 100644 index 2d4b71c3c..000000000 --- a/src/components/main/Notifications.async.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import type { FC } from '../../lib/teact/teact'; - -import { Bundles } from '../../util/moduleLoader'; - -import useModuleLoader from '../../hooks/useModuleLoader'; - -const NotificationsAsync: FC = ({ isOpen }) => { - const Notifications = useModuleLoader(Bundles.Extra, 'Notifications', !isOpen); - - return Notifications ? : undefined; -}; - -export default NotificationsAsync; diff --git a/src/components/modals/ModalContainer.tsx b/src/components/modals/ModalContainer.tsx index 375c07d9c..227333fa6 100644 --- a/src/components/modals/ModalContainer.tsx +++ b/src/components/modals/ModalContainer.tsx @@ -1,5 +1,4 @@ -import type { FC } from '../../lib/teact/teact'; -import { memo } from '../../lib/teact/teact'; +import { type FC, memo } from '../../lib/teact/teact'; import { withGlobal } from '../../global'; import type { TabState } from '../../global/types'; @@ -39,6 +38,7 @@ import LocationAccessModal from './locationAccess/LocationAccessModal.async'; import MapModal from './map/MapModal.async'; import OneTimeMediaModal from './oneTimeMedia/OneTimeMediaModal.async'; import PaidReactionModal from './paidReaction/PaidReactionModal.async'; +import PasskeyModal from './passkey/PasskeyModal.async'; import PreparedMessageModal from './preparedMessage/PreparedMessageModal.async'; import PriceConfirmModal from './priceConfirm/PriceConfirmModal.async'; import ProfileRatingModal from './profileRating/ProfileRatingModal.async'; @@ -111,6 +111,7 @@ type ModalKey = keyof Pick; @@ -177,6 +178,7 @@ const MODALS: ModalRegistry = { profileRatingModal: ProfileRatingModal, quickPreview: QuickPreviewModal, storyStealthModal: StealthModeModal, + isPasskeyModalOpen: PasskeyModal, birthdaySetupModal: BirthdaySetupModal, }; const MODAL_KEYS = Object.keys(MODALS) as ModalKey[]; diff --git a/src/components/modals/passkey/PasskeyModal.async.tsx b/src/components/modals/passkey/PasskeyModal.async.tsx new file mode 100644 index 000000000..49d220504 --- /dev/null +++ b/src/components/modals/passkey/PasskeyModal.async.tsx @@ -0,0 +1,14 @@ +import type { OwnProps } from './PasskeyModal'; + +import { Bundles } from '../../../util/moduleLoader'; + +import useModuleLoader from '../../../hooks/useModuleLoader'; + +const PasskeyModalAsync = (props: OwnProps) => { + const { modal } = props; + const PasskeyModal = useModuleLoader(Bundles.Extra, 'PasskeyModal', !modal); + + return PasskeyModal ? : undefined; +}; + +export default PasskeyModalAsync; diff --git a/src/components/modals/passkey/PasskeyModal.module.scss b/src/components/modals/passkey/PasskeyModal.module.scss new file mode 100644 index 000000000..ad17d1108 --- /dev/null +++ b/src/components/modals/passkey/PasskeyModal.module.scss @@ -0,0 +1,4 @@ +.title { + margin-top: 1rem; + margin-bottom: 0; +} diff --git a/src/components/modals/passkey/PasskeyModal.tsx b/src/components/modals/passkey/PasskeyModal.tsx new file mode 100644 index 000000000..ecba11f22 --- /dev/null +++ b/src/components/modals/passkey/PasskeyModal.tsx @@ -0,0 +1,78 @@ +import { memo, useMemo } from '../../../lib/teact/teact'; +import { getActions } from '../../../global'; + +import type { TabState } from '../../../global/types'; + +import { LOCAL_TGS_PREVIEW_URLS, LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets'; + +import useLang from '../../../hooks/useLang'; +import useLastCallback from '../../../hooks/useLastCallback'; + +import AnimatedIconWithPreview from '../../common/AnimatedIconWithPreview'; +import TableAboutModal, { type TableAboutData } from '../common/TableAboutModal'; + +import styles from './PasskeyModal.module.scss'; + +export type OwnProps = { + modal: TabState['isPasskeyModalOpen']; +}; + +const TOP_STICKER_SIZE = 120; + +const PasskeyModal = ({ + modal, +}: OwnProps) => { + const { closePasskeyModal, startPasskeyRegistration } = getActions(); + + const lang = useLang(); + + const handleClose = useLastCallback(() => { + closePasskeyModal(); + }); + + const handleCreatePasskey = useLastCallback(() => { + startPasskeyRegistration(); + handleClose(); + }); + + const modalData = useMemo(() => { + const header = ( +
+ +

{lang('PasskeyModalTitle')}

+

{lang('PasskeyModalDescription')}

+
+ ); + + const listItemData: TableAboutData = [ + ['key', lang('PasskeyModalFeature1Title'), + lang('PasskeyModalFeature1Description')], + ['animals', lang('PasskeyModalFeature2Title'), + lang('PasskeyModalFeature2Description')], + ['lock', lang('PasskeyModalFeature3Title'), + lang('PasskeyModalFeature3Description')], + ]; + + return { + header, + listItemData, + }; + }, [lang]); + + return ( + + ); +}; + +export default memo(PasskeyModal); diff --git a/src/components/test/Test.tsx b/src/components/test/Test.tsx index 64c10f891..da58f7281 100644 --- a/src/components/test/Test.tsx +++ b/src/components/test/Test.tsx @@ -7,7 +7,8 @@ import type { GlobalState } from '../../global/types'; import ErrorTest from './ErrorTest'; import SubTest from './SubTest'; -type StateProps = Pick & { +type StateProps = { + authState: GlobalState['auth']['state']; globalRand: number; }; @@ -40,7 +41,7 @@ const Test: FC = ({ authState, globalRand }) => { export default withGlobal( (global): Complete => { return { - authState: global.authState, + authState: global.auth.state, globalRand: Math.random(), }; }, diff --git a/src/components/test/TestNoRedundancy.tsx b/src/components/test/TestNoRedundancy.tsx index 9fed540bd..f4b657d5b 100644 --- a/src/components/test/TestNoRedundancy.tsx +++ b/src/components/test/TestNoRedundancy.tsx @@ -14,16 +14,16 @@ document.ondblclick = () => { setGlobal(global); }; -type AStateProps = Pick & { +type AStateProps = Pick & { aValue: number; }; -type BStateProps = Pick & { +type BStateProps = Pick & { bValue: number; derivedAValue: number; }; -type BOwnProps = Pick & { +type BOwnProps = Pick & { aValue: number; }; diff --git a/src/components/ui/Notification.scss b/src/components/ui/Notification.scss index bdd173931..48fb234ec 100644 --- a/src/components/ui/Notification.scss +++ b/src/components/ui/Notification.scss @@ -19,12 +19,13 @@ margin: 0 0.5rem; padding: 0.9375rem; - border-radius: var(--border-radius-default); + border-radius: var(--border-radius-toast); color: #fff; - background-color: rgba(32, 32, 32, 0.8); + background-color: var(--color-toast-background); background-size: 1.5rem; + backdrop-filter: blur(0.5rem); .text-entity-link, .text-entity-link:hover, diff --git a/src/global/actions/api/chats.ts b/src/global/actions/api/chats.ts index 5f3703227..f9f184328 100644 --- a/src/global/actions/api/chats.ts +++ b/src/global/actions/api/chats.ts @@ -531,7 +531,7 @@ addActionHandler('loadAllChats', async (global, actions, payload): Promise global = getGlobal(); - if (global.connectionState !== 'connectionStateReady' || global.authState !== 'authorizationStateReady') { + if (global.connectionState !== 'connectionStateReady' || global.auth.state !== 'authorizationStateReady') { return; } diff --git a/src/global/actions/api/initial.ts b/src/global/actions/api/initial.ts index 657c3cd8f..ff2913b36 100644 --- a/src/global/actions/api/initial.ts +++ b/src/global/actions/api/initial.ts @@ -11,7 +11,9 @@ import { } from '../../../config'; import { updateAppBadge } from '../../../util/appBadge'; import { PASSCODE_IDB_STORE } from '../../../util/browser/idb'; +import { toCredentialRequestOptions } from '../../../util/browser/passkeys'; import { + IS_WEBAUTHN_SUPPORTED, IS_WEBM_SUPPORTED, MAX_BUFFER_SIZE, PLATFORM_ENV, } from '../../../util/browser/windowEnvironment'; import * as cacheApi from '../../../util/cacheApi'; @@ -37,6 +39,7 @@ import { import { clearGlobalForLockScreen, updateManagementProgress, updatePasscodeSettings, } from '../../reducers'; +import { updateAuth } from '../../reducers/auth'; import { selectSharedSettings } from '../../selectors/sharedState'; import { destroySharedStatePort } from '../../shared/sharedStateConnector'; @@ -74,6 +77,7 @@ addActionHandler('initApi', (global, actions): ActionReturnType => { langCode: language, isTestServerRequested: hasTestParam, accountIds, + hasPasskeySupport: IS_WEBAUTHN_SUPPORTED, }); void setShouldEnableDebugLog(Boolean(shouldCollectDebugLogs)); @@ -84,11 +88,10 @@ addActionHandler('setAuthPhoneNumber', (global, actions, payload): ActionReturnT void callApi('provideAuthPhoneNumber', phoneNumber.replace(/[^\d]/g, '')); - return { - ...global, - authIsLoading: true, - authErrorKey: undefined, - }; + return updateAuth(global, { + isLoading: true, + errorKey: undefined, + }); }); addActionHandler('setAuthCode', (global, actions, payload): ActionReturnType => { @@ -96,11 +99,10 @@ addActionHandler('setAuthCode', (global, actions, payload): ActionReturnType => void callApi('provideAuthCode', code); - return { - ...global, - authIsLoading: true, - authErrorKey: undefined, - }; + return updateAuth(global, { + isLoading: true, + errorKey: undefined, + }); }); addActionHandler('setAuthPassword', (global, actions, payload): ActionReturnType => { @@ -108,11 +110,28 @@ addActionHandler('setAuthPassword', (global, actions, payload): ActionReturnType void callApi('provideAuthPassword', password); - return { - ...global, - authIsLoading: true, - authErrorKey: undefined, - }; + return updateAuth(global, { + isLoading: true, + errorKey: undefined, + }); +}); + +addActionHandler('loginWithPasskey', async (global, actions, payload): Promise => { + const passkeyOption = global.auth.passkeyOption; + if (!passkeyOption) return; + + const credential = await navigator.credentials.get(toCredentialRequestOptions(passkeyOption)).catch((e: unknown) => { + actions.showNotification({ + message: { + key: 'PasskeyLoginError', + }, + tabId: getCurrentTabId(), + }); + }); + if (!credential) return; + + const publicKeyCredential = credential as PublicKeyCredential; + callApi('restartAuthWithPasskey', publicKeyCredential.toJSON()); }); addActionHandler('uploadProfilePhoto', async (global, actions, payload): Promise => { @@ -139,30 +158,27 @@ addActionHandler('signUp', (global, actions, payload): ActionReturnType => { void callApi('provideAuthRegistration', { firstName, lastName }); - return { - ...global, - authIsLoading: true, - authErrorKey: undefined, - }; + return updateAuth(global, { + isLoading: true, + errorKey: undefined, + }); }); addActionHandler('returnToAuthPhoneNumber', (global): ActionReturnType => { void callApi('restartAuth'); - return { - ...global, - authErrorKey: undefined, - }; + return updateAuth(global, { + errorKey: undefined, + }); }); addActionHandler('goToAuthQrCode', (global): ActionReturnType => { void callApi('restartAuthWithQr'); - return { - ...global, - authIsLoadingQrCode: true, - authErrorKey: undefined, - }; + return updateAuth(global, { + isLoadingQrCode: true, + errorKey: undefined, + }); }); addActionHandler('saveSession', (global, actions, payload): ActionReturnType => { @@ -254,10 +270,9 @@ addActionHandler('loadNearestCountry', async (global): Promise => { const authNearestCountry = await callApi('fetchNearestCountry'); global = getGlobal(); - global = { - ...global, - authNearestCountry, - }; + global = updateAuth(global, { + nearestCountry: authNearestCountry, + }); setGlobal(global); }); diff --git a/src/global/actions/api/settings.ts b/src/global/actions/api/settings.ts index 1384f75e7..a03d00490 100644 --- a/src/global/actions/api/settings.ts +++ b/src/global/actions/api/settings.ts @@ -2,6 +2,7 @@ import type { ApiPrivacySettings, ApiUsername } from '../../../api/types'; import type { ActionReturnType } from '../../types'; import { ProfileEditProgress, + SettingsScreens, UPLOADING_WALLPAPER_SLUG, } from '../../../types'; @@ -11,6 +12,7 @@ import { MUTE_INDEFINITE_TIMESTAMP, UNMUTE_TIMESTAMP, } from '../../../config'; +import { toCredentialCreationOptions } from '../../../util/browser/passkeys'; import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { buildCollectionByKey } from '../../../util/iteratees'; import { requestPermission, subscribe, unsubscribe } from '../../../util/notifications'; @@ -19,7 +21,7 @@ import requestActionTimeout from '../../../util/requestActionTimeout'; import { getServerTime } from '../../../util/serverTime'; import { callApi } from '../../../api/gramjs'; import { buildApiInputPrivacyRules } from '../../helpers'; -import { addActionHandler, getGlobal, setGlobal } from '../../index'; +import { addActionHandler, getGlobal, getPromiseActions, setGlobal } from '../../index'; import { addBlockedUser, addNotifyExceptions, deletePeerPhoto, removeBlockedUser, replaceSettings, updateChat, @@ -636,9 +638,9 @@ addActionHandler('loadCountryList', async (global, actions, payload): Promise => { - if (global.authNearestCountry) { + if (global.auth.nearestCountry) { const timeFormat = COUNTRIES_WITH_12H_TIME_FORMAT - .has(global.authNearestCountry.toUpperCase()) ? '12h' : '24h'; + .has(global.auth.nearestCountry.toUpperCase()) ? '12h' : '24h'; actions.setSharedSettingOption({ timeFormat }); setTimeFormat(timeFormat); } @@ -947,3 +949,79 @@ addActionHandler('sortChatUsernames', async (global, actions, payload): Promise< setGlobal(global); } }); + +addActionHandler('loadPasskeys', async (global): Promise => { + const result = await callApi('fetchPasskeys'); + if (!result) { + global = getGlobal(); + global = { + ...global, + settings: { + ...global.settings, + passkeys: undefined, + }, + }; + setGlobal(global); + return; + } + + global = getGlobal(); + global = { + ...global, + settings: { + ...global.settings, + passkeys: result.passkeys, + }, + }; + setGlobal(global); +}); + +addActionHandler('startPasskeyRegistration', async (global, actions, payload): Promise => { + const { tabId = getCurrentTabId() } = payload || {}; + + const preparedOptions = await callApi('initPasskeyRegistration'); + if (!preparedOptions) return; + + const options = toCredentialCreationOptions(preparedOptions); + const credential = await navigator.credentials.create(options).catch((e: unknown) => { + if (e instanceof DOMException && e.name === 'NotAllowedError') { + actions.showNotification({ + message: { + key: 'PasskeyCreateError', + }, + tabId, + }); + return undefined; + } + throw e; + }); + if (!credential) return; + const publicKeyCredential = credential as PublicKeyCredential; + + const result = await callApi('registerPasskey', publicKeyCredential.toJSON()); + if (!result) return; + + await getPromiseActions().loadPasskeys(); + actions.openSettingsScreen({ screen: SettingsScreens.Passkeys, tabId }); +}); + +addActionHandler('deletePasskey', async (global, actions, payload): Promise => { + const { id } = payload; + + const passkeys = global.settings.passkeys; + if (passkeys?.length) { + const filteredPasskeys = passkeys.filter((passkey) => passkey.id !== id); + global = { + ...global, + settings: { + ...global.settings, + passkeys: filteredPasskeys, + }, + }; + setGlobal(global); + } + + await callApi('deletePasskey', { id }); + + actions.loadPasskeys(); +}); diff --git a/src/global/actions/api/sync.ts b/src/global/actions/api/sync.ts index c94c7368e..a5caa2a5b 100644 --- a/src/global/actions/api/sync.ts +++ b/src/global/actions/api/sync.ts @@ -313,15 +313,15 @@ function loadTopMessages(global: T, chatId: string, threa let previousGlobal: GlobalState | undefined; // RAF can be unreliable when device goes into sleep mode, so sync logic is handled outside any component addCallback((global: GlobalState) => { - const { connectionState, authState, isSynced } = global; + const { connectionState, auth, isSynced } = global; const { isMasterTab } = selectTabState(global); if (!isMasterTab || isSynced || (previousGlobal?.connectionState === connectionState - && previousGlobal?.authState === authState)) { + && previousGlobal?.auth.state === auth.state)) { previousGlobal = global; return; } - if (connectionState === 'connectionStateReady' && authState === 'authorizationStateReady') { + if (connectionState === 'connectionStateReady' && auth.state === 'authorizationStateReady') { getActions().sync(); } diff --git a/src/global/actions/apiUpdaters/initial.ts b/src/global/actions/apiUpdaters/initial.ts index 440af3ac3..d0554c81c 100644 --- a/src/global/actions/apiUpdaters/initial.ts +++ b/src/global/actions/apiUpdaters/initial.ts @@ -3,8 +3,10 @@ import type { ApiUpdateAuthorizationState, ApiUpdateConnectionState, ApiUpdateCurrentUser, + ApiUpdatePasskeyOption, ApiUpdateServerTimeOffset, ApiUpdateSession, + ApiUpdateUserAlreadyAuthorized, } from '../../../api/types'; import type { LangCode } from '../../../types'; import type { RequiredGlobalActions } from '../../index'; @@ -13,6 +15,7 @@ import type { ActionReturnType, GlobalState } from '../../types'; import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { getShippingError, shouldClosePaymentModal } from '../../../util/getReadableErrorText'; import { unique } from '../../../util/iteratees'; +import { getAccountsInfo, getAccountSlotUrl } from '../../../util/multiaccount'; import { oldSetLanguage } from '../../../util/oldLangProvider'; import { clearWebTokenAuth } from '../../../util/routing'; import { setServerTimeOffset } from '../../../util/serverTime'; @@ -20,9 +23,10 @@ import { updateSessionUserId } from '../../../util/sessions'; import { forceWebsync } from '../../../util/websync'; import { isChatChannel, isChatSuperGroup } from '../../helpers'; import { - addActionHandler, getGlobal, setGlobal, + addActionHandler, getActions, getGlobal, setGlobal, } from '../../index'; import { updateUser, updateUserFullInfo } from '../../reducers'; +import { updateAuth } from '../../reducers/auth'; import { updateTabState } from '../../reducers/tabs'; import { selectTabState } from '../../selectors'; import { selectSharedSettings } from '../../selectors/sharedState'; @@ -41,10 +45,18 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { onUpdateAuthorizationError(global, update); break; + case 'updateUserAlreadyAuthorized': + onUpdateUserAlreadyAuthorized(global, update); + break; + case 'updateWebAuthTokenFailed': onUpdateWebAuthTokenFailed(global); break; + case 'updatePasskeyOption': + onUpdatePasskeyOption(global, update); + break; + case 'updateConnectionState': onUpdateConnectionState(global, actions, update); break; @@ -116,58 +128,49 @@ function onUpdateApiReady(global: T) { } function onUpdateAuthorizationState(global: T, update: ApiUpdateAuthorizationState) { - global = getGlobal(); - - const wasAuthReady = global.authState === 'authorizationStateReady'; + const wasAuthReady = global.auth.state === 'authorizationStateReady'; const authState = update.authorizationState; - global = { - ...global, - authState, - authIsLoading: false, - }; + global = updateAuth(global, { + state: authState, + isLoading: false, + }); setGlobal(global); - global = getGlobal(); switch (authState) { case 'authorizationStateLoggingOut': void forceWebsync(false); - global = { - ...global, + global = updateAuth(global, { isLoggingOut: true, - }; + }); setGlobal(global); break; case 'authorizationStateWaitCode': - global = { - ...global, - authIsCodeViaApp: update.isCodeViaApp, - }; + global = updateAuth(global, { + isCodeViaApp: update.isCodeViaApp, + }); setGlobal(global); break; case 'authorizationStateWaitPassword': - global = { - ...global, - authHint: update.hint, - }; + global = updateAuth(global, { + hint: update.hint, + }); if (update.noReset) { - global = { - ...global, + global = updateAuth(global, { hasWebAuthTokenPasswordRequired: true, - }; + }); } setGlobal(global); break; case 'authorizationStateWaitQrCode': - global = { - ...global, - authIsLoadingQrCode: false, - authQrCode: update.qrCode, - }; + global = updateAuth(global, { + isLoadingQrCode: false, + qrCode: update.qrCode, + }); setGlobal(global); break; case 'authorizationStateReady': { @@ -177,10 +180,9 @@ function onUpdateAuthorizationState(global: T, update: Ap void forceWebsync(true); - global = { - ...global, + global = updateAuth(global, { isLoggingOut: false, - }; + }); Object.values(global.byTabId).forEach(({ id: tabId }) => { global = updateTabState(global, { inactiveReason: undefined, @@ -194,22 +196,44 @@ function onUpdateAuthorizationState(global: T, update: Ap } function onUpdateAuthorizationError(global: T, update: ApiUpdateAuthorizationError) { - // TODO: Investigate why TS is not happy with spread for lang related types - global = { - ...global, - }; - global.authErrorKey = update.errorKey; + if (update.errorCode === 'PASSKEY_CREDENTIAL_NOT_FOUND') { + getActions().showNotification({ + message: update.errorKey, + tabId: getCurrentTabId(), + }); + return; + } + + global = updateAuth(global, { + errorKey: update.errorKey, + }); setGlobal(global); } +function onUpdateUserAlreadyAuthorized(global: T, update: ApiUpdateUserAlreadyAuthorized) { + const { userId } = update; + if (global.currentUserId === userId) return; + + const accounts = getAccountsInfo(); + const slot = Object.entries(accounts).find(([_, info]) => info.userId === userId)?.[0]; + if (!slot) return; + const url = getAccountSlotUrl(Number(slot)); + window.location.replace(url); +} + function onUpdateWebAuthTokenFailed(global: T) { clearWebTokenAuth(); - global = getGlobal(); - global = { - ...global, + global = updateAuth(global, { hasWebAuthTokenFailed: true, - }; + }); + setGlobal(global); +} + +function onUpdatePasskeyOption(global: T, update: ApiUpdatePasskeyOption) { + global = updateAuth(global, { + passkeyOption: update.option, + }); setGlobal(global); } @@ -218,7 +242,6 @@ function onUpdateConnectionState( ) { const { connectionState } = update; - global = getGlobal(); const tabState = selectTabState(global, getCurrentTabId()); if (connectionState === 'connectionStateReady' && tabState.isMasterTab && tabState.multitabNextAction) { // @ts-ignore @@ -258,7 +281,7 @@ function onUpdateConnectionState( function onUpdateSession(global: T, actions: RequiredGlobalActions, update: ApiUpdateSession) { const { sessionData } = update; - const { authRememberMe, authState } = global; + const { rememberMe, state } = global.auth; const isEmpty = !sessionData || !sessionData.mainDcId; const isTest = sessionData?.isTest; @@ -273,7 +296,7 @@ function onUpdateSession(global: T, actions: RequiredGlob setGlobal(global); } - if (!authRememberMe || authState !== 'authorizationStateReady' || isEmpty) { + if (!rememberMe || state !== 'authorizationStateReady' || isEmpty) { return; } diff --git a/src/global/actions/ui/initial.ts b/src/global/actions/ui/initial.ts index e43958b12..d7d1c0d9f 100644 --- a/src/global/actions/ui/initial.ts +++ b/src/global/actions/ui/initial.ts @@ -1,5 +1,6 @@ import { addCallback } from '../../../lib/teact/teactn'; +import type { ApiNotification } from '../../../api/types'; import type { LangCode } from '../../../types'; import type { ActionReturnType, GlobalState } from '../../types'; @@ -10,6 +11,7 @@ import { IS_MAC_OS, IS_SAFARI, IS_TOUCH_ENV, IS_WINDOWS, } from '../../../util/browser/windowEnvironment'; import { getCurrentTabId } from '../../../util/establishMultitabRole'; +import generateUniqueId from '../../../util/generateUniqueId'; import { subscribe, unsubscribe } from '../../../util/notifications'; import { oldSetLanguage } from '../../../util/oldLangProvider'; import { decryptSessionByCurrentHash } from '../../../util/passcode'; @@ -22,6 +24,7 @@ import { callApi } from '../../../api/gramjs'; import { clearCaching, setupCaching } from '../../cache'; import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { updateSharedSettings } from '../../reducers'; +import { updateAuth } from '../../reducers/auth'; import { updateTabState } from '../../reducers/tabs'; import { selectCanAnimateInterface, @@ -216,24 +219,21 @@ addActionHandler('setIsUiReady', (global, actions, payload): ActionReturnType => addActionHandler('setAuthPhoneNumber', (global, actions, payload): ActionReturnType => { const { phoneNumber } = payload; - return { - ...global, - authPhoneNumber: phoneNumber, - }; + return updateAuth(global, { + phoneNumber, + }); }); addActionHandler('setAuthRememberMe', (global, actions, payload): ActionReturnType => { - return { - ...global, - authRememberMe: Boolean(payload.value), - }; + return updateAuth(global, { + rememberMe: Boolean(payload.value), + }); }); addActionHandler('clearAuthErrorKey', (global): ActionReturnType => { - return { - ...global, - authErrorKey: undefined, - }; + return updateAuth(global, { + errorKey: undefined, + }); }); addActionHandler('disableHistoryAnimations', (global, actions, payload): ActionReturnType => { @@ -256,3 +256,33 @@ addActionHandler('disableHistoryAnimations', (global, actions, payload): ActionR }, tabId); setGlobal(global, { forceSyncOnIOs: true }); }); + +addActionHandler('showNotification', (global, actions, payload): ActionReturnType => { + const { tabId = getCurrentTabId(), ...notification } = payload; + const hasLocalId = notification.localId; + notification.localId ||= generateUniqueId(); + + const newNotifications = [...selectTabState(global, tabId).notifications]; + const existingNotificationIndex = newNotifications.findIndex((n) => ( + hasLocalId ? n.localId === notification.localId : n.message === notification.message + )); + if (existingNotificationIndex !== -1) { + newNotifications.splice(existingNotificationIndex, 1); + } + + newNotifications.push(notification as ApiNotification); + + return updateTabState(global, { + notifications: newNotifications, + }, tabId); +}); + +addActionHandler('dismissNotification', (global, actions, payload): ActionReturnType => { + const { tabId = getCurrentTabId() } = payload; + const newNotifications = selectTabState(global, tabId) + .notifications.filter(({ localId }) => localId !== payload.localId); + + return updateTabState(global, { + notifications: newNotifications, + }, tabId); +}); diff --git a/src/global/actions/ui/misc.ts b/src/global/actions/ui/misc.ts index 31d9b4090..6e09ddf7d 100644 --- a/src/global/actions/ui/misc.ts +++ b/src/global/actions/ui/misc.ts @@ -1,6 +1,6 @@ import { addCallback } from '../../../lib/teact/teactn'; -import type { ApiError, ApiNotification } from '../../../api/types'; +import type { ApiError } from '../../../api/types'; import type { ActionReturnType, GlobalState } from '../../types'; import { @@ -12,7 +12,6 @@ import { IS_TAURI } from '../../../util/browser/globalEnvironment'; import { IS_WAVE_TRANSFORM_SUPPORTED } from '../../../util/browser/windowEnvironment'; import { getAllMultitabTokens, getCurrentTabId, reestablishMasterToSelf } from '../../../util/establishMultitabRole'; import { getAllNotificationsCount } from '../../../util/folderManager'; -import generateUniqueId from '../../../util/generateUniqueId'; import getIsAppUpdateNeeded from '../../../util/getIsAppUpdateNeeded'; import getReadableErrorText from '../../../util/getReadableErrorText'; import { compact, unique } from '../../../util/iteratees'; @@ -332,26 +331,6 @@ addActionHandler('reorderStickerSets', (global, actions, payload): ActionReturnT }; }); -addActionHandler('showNotification', (global, actions, payload): ActionReturnType => { - const { tabId = getCurrentTabId(), ...notification } = payload; - const hasLocalId = notification.localId; - notification.localId ||= generateUniqueId(); - - const newNotifications = [...selectTabState(global, tabId).notifications]; - const existingNotificationIndex = newNotifications.findIndex((n) => ( - hasLocalId ? n.localId === notification.localId : n.message === notification.message - )); - if (existingNotificationIndex !== -1) { - newNotifications.splice(existingNotificationIndex, 1); - } - - newNotifications.push(notification as ApiNotification); - - return updateTabState(global, { - notifications: newNotifications, - }, tabId); -}); - addActionHandler('showAllowedMessageTypesNotification', (global, actions, payload): ActionReturnType => { const { chatId, messageListType, tabId = getCurrentTabId() } = payload; @@ -405,16 +384,6 @@ addActionHandler('showAllowedMessageTypesNotification', (global, actions, payloa }); }); -addActionHandler('dismissNotification', (global, actions, payload): ActionReturnType => { - const { tabId = getCurrentTabId() } = payload; - const newNotifications = selectTabState(global, tabId) - .notifications.filter(({ localId }) => localId !== payload.localId); - - return updateTabState(global, { - notifications: newNotifications, - }, tabId); -}); - addActionHandler('showDialog', (global, actions, payload): ActionReturnType => { const { data, tabId = getCurrentTabId() } = payload; diff --git a/src/global/actions/ui/settings.ts b/src/global/actions/ui/settings.ts index 2d561f7e1..7ed76fc74 100644 --- a/src/global/actions/ui/settings.ts +++ b/src/global/actions/ui/settings.ts @@ -12,6 +12,7 @@ import { applyPerformanceSettings } from '../../../util/perfomanceSettings'; import switchTheme from '../../../util/switchTheme'; import { updatePeerColors } from '../../../util/theme'; import { callApi, setShouldEnableDebugLog } from '../../../api/gramjs'; +import { addTabStateResetterAction } from '../../helpers/meta'; import { addActionHandler, getActions, setGlobal, } from '../../index'; @@ -226,3 +227,12 @@ addActionHandler('closeShareChatFolderModal', (global, actions, payload): Action shareFolderScreen: undefined, }, tabId); }); + +addActionHandler('openPasskeyModal', (global, actions, payload): ActionReturnType => { + const { tabId = getCurrentTabId() } = payload || {}; + return updateTabState(global, { + isPasskeyModalOpen: true, + }, tabId); +}); + +addTabStateResetterAction('closePasskeyModal', 'isPasskeyModalOpen'); diff --git a/src/global/cache.ts b/src/global/cache.ts index a4e80d71b..32feb3383 100644 --- a/src/global/cache.ts +++ b/src/global/cache.ts @@ -362,11 +362,16 @@ function unsafeMigrateCache(cached: GlobalState, initialState: GlobalState) { cached.sharedState.settings.shouldWarnAboutFiles = true; untypedCached.sharedState.settings.shouldWarnAboutSvg = undefined; } + + if (!cached.auth) { + cached.auth = initialState.auth; + cached.auth.rememberMe = untypedCached.rememberMe; + } } function updateCache(force?: boolean) { const global = getGlobal(); - if (isRemovingCache || !isCaching || global.isLoggingOut || (!force && getIsHeavyAnimating())) { + if (isRemovingCache || !isCaching || global.auth.isLoggingOut || (!force && getIsHeavyAnimating())) { return; } @@ -400,10 +405,7 @@ function reduceGlobal(global: T) { ...pick(global, [ 'appConfig', 'config', - 'authState', - 'authPhoneNumber', - 'authRememberMe', - 'authNearestCountry', + 'auth', 'attachMenu', 'currentUserId', 'contactList', diff --git a/src/global/init.ts b/src/global/init.ts index 9fcfeb899..6f35257a1 100644 --- a/src/global/init.ts +++ b/src/global/init.ts @@ -110,7 +110,7 @@ addActionHandler('init', (global, actions, payload): ActionReturnType => { const parsedMessageList = parseLocationHash(global.currentUserId); - if (global.authState !== 'authorizationStateReady' + if (global.auth.state !== 'authorizationStateReady' && !global.passcode.hasPasscode && !global.passcode.isScreenLocked) { Object.values(global.byTabId).forEach(({ id: otherTabId }) => { if (otherTabId === tabId) return; diff --git a/src/global/initialState.ts b/src/global/initialState.ts index 014a79096..99c8a5b86 100644 --- a/src/global/initialState.ts +++ b/src/global/initialState.ts @@ -113,7 +113,9 @@ export const INITIAL_GLOBAL_STATE: GlobalState = { lastPlaybackRate: DEFAULT_PLAYBACK_RATE, }, - authRememberMe: true, + auth: { + rememberMe: true, + }, countryList: { phoneCodes: [], general: [], diff --git a/src/global/reducers/auth.ts b/src/global/reducers/auth.ts new file mode 100644 index 000000000..edf014c4b --- /dev/null +++ b/src/global/reducers/auth.ts @@ -0,0 +1,15 @@ +import type { + GlobalState, +} from '../types'; + +export function updateAuth( + global: T, update: Partial, +): T { + return { + ...global, + auth: { + ...global.auth, + ...update, + }, + }; +} diff --git a/src/global/selectors/settings.ts b/src/global/selectors/settings.ts index e3d45228b..1c80569ff 100644 --- a/src/global/selectors/settings.ts +++ b/src/global/selectors/settings.ts @@ -22,7 +22,7 @@ export function selectLanguageCode(global: T) { export function selectCanSetPasscode(global: T) { // TODO[passcode]: remove this when multiacc passcode is implemented const accounts = getAccountsInfo(); - return global.authRememberMe && !ACCOUNT_SLOT && Object.keys(accounts).length === 1; + return global.auth.rememberMe && !ACCOUNT_SLOT && Object.keys(accounts).length === 1; } export function selectTranslationLanguage(global: T) { diff --git a/src/global/types/actions.ts b/src/global/types/actions.ts index fd53bb1a3..ce1b6543f 100644 --- a/src/global/types/actions.ts +++ b/src/global/types/actions.ts @@ -147,6 +147,7 @@ export interface ActionPayloads { tabId?: number; }; goToAuthQrCode: undefined; + loginWithPasskey: undefined; // stickers & GIFs setStickerSearchQuery: { query?: string } & WithTabId; @@ -250,6 +251,15 @@ export interface ActionPayloads { notificationSoundVolume?: number; }; loadLanguages: undefined; + + loadPasskeys: undefined; + startPasskeyRegistration: WithTabId | undefined; + deletePasskey: { + id: string; + }; + openPasskeyModal: WithTabId | undefined; + closePasskeyModal: WithTabId | undefined; + loadPrivacySettings: { skipIfCached?: boolean; }; diff --git a/src/global/types/globalState.ts b/src/global/types/globalState.ts index bdd502940..2b188a1b8 100644 --- a/src/global/types/globalState.ts +++ b/src/global/types/globalState.ts @@ -16,6 +16,8 @@ import type { ApiMessage, ApiNotifyPeerType, ApiPaidReactionPrivacyType, + ApiPasskey, + ApiPasskeyOption, ApiPeerColors, ApiPeerNotifySettings, ApiPeerPhotos, @@ -88,8 +90,6 @@ export type GlobalState = { byId: Record; hash: number; }; - hasWebAuthTokenFailed?: boolean; - hasWebAuthTokenPasswordRequired?: true; isCacheApiSupported?: boolean; connectionState?: ApiUpdateConnectionStateType; currentUserId?: string; @@ -146,20 +146,25 @@ export type GlobalState = { isLoading?: boolean; }; - // TODO Move to `auth`. - isLoggingOut?: boolean; - authState?: ApiUpdateAuthorizationStateType; - authPhoneNumber?: string; - authIsLoading?: boolean; - authIsLoadingQrCode?: boolean; - authErrorKey?: RegularLangFnParameters; - authRememberMe?: boolean; - authNearestCountry?: string; - authIsCodeViaApp?: boolean; - authHint?: string; - authQrCode?: { - token: string; - expires: number; + auth: { + isLoggingOut?: boolean; + state?: ApiUpdateAuthorizationStateType; + phoneNumber?: string; + isLoading?: boolean; + isLoadingQrCode?: boolean; + errorKey?: RegularLangFnParameters; + rememberMe?: boolean; + nearestCountry?: string; + isCodeViaApp?: boolean; + hint?: string; + qrCode?: { + token: string; + expires: number; + }; + passkeyOption?: ApiPasskeyOption; + + hasWebAuthTokenFailed?: true; + hasWebAuthTokenPasswordRequired?: true; }; countryList: { phoneCodes: ApiCountryCode[]; @@ -437,6 +442,7 @@ export type GlobalState = { botVerificationShownPeerIds: string[]; themes: Partial>; accountDaysTtl: number; + passkeys?: ApiPasskey[]; }; push?: { diff --git a/src/global/types/tabState.ts b/src/global/types/tabState.ts index 6a5d5f5e1..aa6e6dcb2 100644 --- a/src/global/types/tabState.ts +++ b/src/global/types/tabState.ts @@ -920,6 +920,8 @@ export type TabState = { threadId?: ThreadId; }; + isPasskeyModalOpen?: boolean; + isWaitingForStarGiftUpgrade?: true; isWaitingForStarGiftTransfer?: true; insertingPeerIdMention?: string; diff --git a/src/hooks/useMultiaccountInfo.ts b/src/hooks/useMultiaccountInfo.ts index a2fdba413..a21b7af1f 100644 --- a/src/hooks/useMultiaccountInfo.ts +++ b/src/hooks/useMultiaccountInfo.ts @@ -20,7 +20,7 @@ const REFRESH_INTERVAL = 1000 * 60; const PREVIEW_SIZE = 72; export default function useMultiaccountInfo(currentUser?: ApiUser) { - const isUpdater = Boolean(currentUser) && getGlobal().authRememberMe; + const isUpdater = Boolean(currentUser) && getGlobal().auth.rememberMe; const isSynced = useSelector(selectIsSynced); const [accountsInfo, setAccountsInfo] = useState(() => getAccountsInfo()); diff --git a/src/index.tsx b/src/index.tsx index 72efb8400..9c94a6c63 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -78,7 +78,7 @@ async function init() { getActions() .switchMultitabRole({ isMasterTab }, { forceSyncOnIOs: true }); }); - const shouldReestablishMasterToSelf = getGlobal().authState !== 'authorizationStateReady'; + const shouldReestablishMasterToSelf = getGlobal().auth.state !== 'authorizationStateReady'; establishMultitabRole(shouldReestablishMasterToSelf); if (DEBUG) { diff --git a/src/lib/gramjs/Helpers.ts b/src/lib/gramjs/Helpers.ts index 350de4a66..813278455 100644 --- a/src/lib/gramjs/Helpers.ts +++ b/src/lib/gramjs/Helpers.ts @@ -130,7 +130,7 @@ export async function generateKeyDataFromNonce( } export function convertToLittle(buf: Uint32Array) { - const correct = Buffer.alloc(buf.length * 4); + const correct = Buffer.allocUnsafe(buf.length * 4); for (let i = 0; i < buf.length; i++) { correct.writeUInt32BE(buf[i], i * 4); diff --git a/src/lib/gramjs/client/auth.ts b/src/lib/gramjs/client/auth.ts index 0989301d4..f00ccaccb 100644 --- a/src/lib/gramjs/client/auth.ts +++ b/src/lib/gramjs/client/auth.ts @@ -1,10 +1,16 @@ +import type { ApiPasskeyOption } from '../../../api/types'; import type TelegramClient from './TelegramClient'; import type { Update } from './TelegramClient'; +import { DEBUG } from '../../../config'; +import { base64UrlToString } from '../../../util/encoding/base64'; import { tryParseBigInt } from '../../../util/numbers'; import { getServerTime } from '../../../util/serverTime'; import { DEFAULT_PRIMITIVES } from '../../../api/gramjs/gramjsBuilders'; -import { RPCError } from '../errors'; +import { buildInputPasskeyCredential } from '../../../api/gramjs/gramjsBuilders/passkeys'; +import { + PasskeyCredentialNotFoundError, PasskeyLoginRequestedError, RPCError, UserAlreadyAuthorizedError, +} from '../errors'; import Api from '../tl/api'; import { sleep } from '../Helpers'; @@ -14,6 +20,7 @@ import { getDisplayName } from '../Utils'; export interface UserAuthParams { phoneNumber: string | (() => Promise); webAuthTokenFailed: () => void; + onPasskeyOption: (passkeyOption: ApiPasskeyOption) => void; phoneCode: (isCodeViaApp?: boolean) => Promise; password: (hint?: string, noReset?: boolean) => Promise; firstAndLastNames: () => Promise<[string, string?]>; @@ -24,6 +31,7 @@ export interface UserAuthParams { shouldThrowIfUnauthorized?: boolean; webAuthToken?: string; accountIds?: string[]; + hasPasskeySupport?: boolean; mockScenario?: string; } @@ -36,7 +44,9 @@ interface ApiCredentials { apiHash: string; } -const DEFAULT_INITIAL_METHOD = 'phoneNumber'; +type AuthMethod = 'phoneNumber' | 'qrCode'; +const DEFAULT_INITIAL_METHOD: AuthMethod = 'phoneNumber'; +let lastUsedMethod: AuthMethod = DEFAULT_INITIAL_METHOD; export async function authFlow( client: TelegramClient, @@ -61,6 +71,8 @@ export function signInUserWithPreferredMethod( ): Promise { const { initialMethod = DEFAULT_INITIAL_METHOD } = authParams; + refreshPasskeyLoginOption(client, apiCredentials, authParams); + if (initialMethod === 'phoneNumber') { return signInUser(client, apiCredentials, authParams); } else { @@ -68,6 +80,22 @@ export function signInUserWithPreferredMethod( } } +function refreshPasskeyLoginOption( + client: TelegramClient, apiCredentials: ApiCredentials, authParams: UserAuthParams, +) { + if (!authParams.hasPasskeySupport) return; + obtainPasskeyLoginOption(client, apiCredentials).then((passkeyOption) => { + if (passkeyOption) { + authParams.onPasskeyOption(passkeyOption); + } + }).catch((err: unknown) => { + if (DEBUG) { + // eslint-disable-next-line no-console + console.error('Failed to obtain passkey login option:', err); + } + }); +} + export async function checkAuthorization(client: TelegramClient, shouldThrow = false) { try { await client.invoke(new Api.updates.GetState()); @@ -114,6 +142,7 @@ async function signInUser( let phoneNumber; let phoneCodeHash; let isCodeViaApp = false; + lastUsedMethod = 'phoneNumber'; while (true) { try { @@ -122,7 +151,11 @@ async function signInUser( phoneNumber = await authParams.phoneNumber(); } catch (err: unknown) { if (err instanceof Error && err.message === 'RESTART_AUTH_WITH_QR') { - return signInUserWithQrCode(client, apiCredentials, authParams); + return await signInUserWithQrCode(client, apiCredentials, authParams); + } + + if (err instanceof PasskeyLoginRequestedError) { + return await signInUserWithPasskey(client, apiCredentials, authParams, err.credentialJson); } throw err; @@ -235,6 +268,8 @@ async function signInUserWithQrCode( const { apiId, apiHash } = apiCredentials; const exceptIds = authParams.accountIds?.map((id) => tryParseBigInt(id)).filter(Boolean) || []; + lastUsedMethod = 'qrCode'; + const inputPromise = (async () => { // eslint-disable-next-line no-constant-condition while (1) { @@ -277,6 +312,10 @@ async function signInUserWithQrCode( return await signInUser(client, apiCredentials, authParams); } + if (err instanceof PasskeyLoginRequestedError) { + return await signInUserWithPasskey(client, apiCredentials, authParams, err.credentialJson); + } + throw err; } finally { isScanningComplete = true; @@ -315,6 +354,102 @@ async function signInUserWithQrCode( throw undefined; } +export async function obtainPasskeyLoginOption( + client: TelegramClient, apiCredentials: ApiCredentials, +): Promise { + const { apiId, apiHash } = apiCredentials; + const passkeyLoginOptions = await client.invoke(new Api.auth.InitPasskeyLogin({ + apiId, + apiHash, + })); + + if (!passkeyLoginOptions) return undefined; + + try { + return JSON.parse(passkeyLoginOptions.options.data) as ApiPasskeyOption; + } catch (err: unknown) { + if (DEBUG) { + // eslint-disable-next-line no-console + console.warn('Failed to parse passkey login options:', err); + } + } + return undefined; +} + +export async function signInUserWithPasskey( + client: TelegramClient, + apiCredentials: ApiCredentials, + authParams: UserAuthParams, + credentialJson: PublicKeyCredentialJSON, +): Promise { + try { + if (!credentialJson.response.userHandle) { + throw new Error('User handle is empty'); + } + + const userHandle = base64UrlToString(credentialJson.response.userHandle); + const [userDcIdStr, userId] = userHandle.split(':'); + if (!userDcIdStr || !userId || isNaN(Number(userDcIdStr))) { + throw new Error('Unexpected user handle format'); + } + + const userDcId = Number(userDcIdStr); + if (authParams.accountIds?.includes(userId)) { + throw new UserAlreadyAuthorizedError(userId); + } + + const fromDcId = client.session.dcId; + const unsignedFromAuthKeyId = client.session.getAuthKey(fromDcId).keyId; // Auth key id bytes are stored as unsigned 64-bit integer, long is signed + const signedFromAuthKeyId = unsignedFromAuthKeyId ? BigInt.asIntN(64, unsignedFromAuthKeyId) : undefined; + + const isSwitchingDc = fromDcId !== userDcId; + if (isSwitchingDc) { + await client._switchDC(userDcId); + } + + const result = await client.invoke(new Api.auth.FinishPasskeyLogin({ + credential: buildInputPasskeyCredential(credentialJson), + fromDcId: isSwitchingDc ? fromDcId : undefined, + fromAuthKeyId: isSwitchingDc ? signedFromAuthKeyId : undefined, + })); + + if (result instanceof Api.auth.Authorization) { + return result.user; + } + + throw new Error('Unexpected sign up in passkey login'); + } catch (err: unknown) { + // We cannot call finishPasskeyLogin again with the same challenge + refreshPasskeyLoginOption(client, apiCredentials, authParams); + + const isPasskeyUnknownError = err instanceof PasskeyCredentialNotFoundError; + + if (isPasskeyUnknownError) { + authParams.onError(err); + } + + if (err instanceof Error) { + if (err.message === 'RESTART_AUTH' || (isPasskeyUnknownError && lastUsedMethod === 'phoneNumber')) { + return signInUser(client, apiCredentials, authParams); + } + + if (err.message === 'RESTART_AUTH_WITH_QR' || (isPasskeyUnknownError && lastUsedMethod === 'qrCode')) { + return signInUserWithQrCode(client, apiCredentials, authParams); + } + } + + if (err instanceof RPCError && err.errorMessage === 'SESSION_PASSWORD_NEEDED') { + return signInWithPassword(client, apiCredentials, authParams); + } + + if (err instanceof Error) { + authParams.onError(err); + } + + throw err; + } +} + async function sendCode( client: TelegramClient, apiCredentials: ApiCredentials, phoneNumber: string, forceSMS = false, ): Promise<{ diff --git a/src/lib/gramjs/errors/RPCErrorList.ts b/src/lib/gramjs/errors/RPCErrorList.ts index e5ed83914..6743a4575 100644 --- a/src/lib/gramjs/errors/RPCErrorList.ts +++ b/src/lib/gramjs/errors/RPCErrorList.ts @@ -148,6 +148,31 @@ export class PasswordFreshError extends BadRequestError { } } +export class PasskeyLoginRequestedError extends Error { + public credentialJson: PublicKeyCredentialJSON; + + constructor(credentialJson: PublicKeyCredentialJSON) { + super('Passkey login requested'); + this.message = 'RESTART_AUTH_WITH_PASSKEY'; + this.credentialJson = credentialJson; + } +} + +export class UserAlreadyAuthorizedError extends Error { + public userId: string; + constructor(userId: string) { + super('User already authorized'); + this.message = 'USER_ALREADY_AUTHORIZED'; + this.userId = userId; + } +} + +export class PasskeyCredentialNotFoundError extends RPCError { + constructor(args: any) { + super('PASSKEY_CREDENTIAL_NOT_FOUND', args.request); + } +} + export const rpcErrorRe = new Map([ [/FILE_MIGRATE_(\d+)/, FileMigrateError], [/FLOOD_TEST_PHONE_WAIT_(\d+)/, FloodTestPhoneWaitError], @@ -161,4 +186,5 @@ export const rpcErrorRe = new Map([ [/EMAIL_UNCONFIRMED_(\d+)/, EmailUnconfirmedError], [/PASSWORD_TOO_FRESH_(\d+)/, PasswordFreshError], [/^Timeout$/, TimedOutError], + [/PASSKEY_CREDENTIAL_NOT_FOUND/, PasskeyCredentialNotFoundError], ]); diff --git a/src/lib/gramjs/network/MTProtoSender.ts b/src/lib/gramjs/network/MTProtoSender.ts index a58863945..c0bf86aed 100644 --- a/src/lib/gramjs/network/MTProtoSender.ts +++ b/src/lib/gramjs/network/MTProtoSender.ts @@ -548,8 +548,9 @@ export default class MTProtoSender { try { await this._fallbackConnection?.send(data); } catch (e: any) { - this._log.error(e); this._log.info('Connection closed while sending data'); + // eslint-disable-next-line no-console + console.error(e); this._longPollLoopHandle = undefined; this.isSendingLongPoll = false; if (!this.userDisconnected) { @@ -651,8 +652,9 @@ export default class MTProtoSender { await this.getConnection()!.send(data); } catch (e: any) { this.logWithIndex.debug(`Connection closed while sending data ${e}`); - this._log.error(e); this._log.info('Connection closed while sending data'); + // eslint-disable-next-line no-console + console.error(e); this._sendLoopHandle = undefined; if (!this.userDisconnected) { this.reconnect(); @@ -694,8 +696,9 @@ export default class MTProtoSender { // this._log.info('Connection closed while receiving data'); /** when the server disconnects us we want to reconnect */ if (!this.userDisconnected) { - this._log.error(e); this._log.warn('Connection closed while receiving data'); + // eslint-disable-next-line no-console + console.error(e); this.reconnect(); } this._recvLoopHandle = undefined; @@ -731,7 +734,8 @@ export default class MTProtoSender { return; } else { this._log.error('Unhandled error while receiving data'); - this._log.error(e); + // eslint-disable-next-line no-console + console.error(e); this.reconnect(); this._recvLoopHandle = undefined; return; @@ -750,7 +754,8 @@ export default class MTProtoSender { } } else { this._log.error('Unhandled error while receiving data'); - this._log.error(e); + // eslint-disable-next-line no-console + console.error(e); } } diff --git a/src/lib/gramjs/tl/apiTl.ts b/src/lib/gramjs/tl/apiTl.ts index 9f71f9195..0cbbfdb8c 100644 --- a/src/lib/gramjs/tl/apiTl.ts +++ b/src/lib/gramjs/tl/apiTl.ts @@ -1544,6 +1544,8 @@ auth.dropTempAuthKeys#8e48a188 except_auth_keys:Vector = Bool; auth.exportLoginToken#b7e085fe api_id:int api_hash:string except_ids:Vector = auth.LoginToken; auth.importLoginToken#95ac5ce4 token:bytes = auth.LoginToken; auth.importWebTokenAuthorization#2db873a9 api_id:int api_hash:string web_auth_token:string = auth.Authorization; +auth.initPasskeyLogin#518ad0b7 api_id:int api_hash:string = auth.PasskeyLoginOptions; +auth.finishPasskeyLogin#9857ad07 flags:# credential:InputPasskeyCredential from_dc_id:flags.0?int from_auth_key_id:flags.0?long = auth.Authorization; account.registerDevice#ec86017a flags:# no_muted:flags.0?true token_type:int token:string app_sandbox:Bool secret:bytes other_uids:Vector = Bool; account.unregisterDevice#6a0d3206 token_type:int token:string other_uids:Vector = Bool; account.updateNotifySettings#84be5b93 peer:InputNotifyPeer settings:InputPeerNotifySettings = Bool; @@ -1593,6 +1595,10 @@ account.getCollectibleEmojiStatuses#2e7b4543 hash:long = account.EmojiStatuses; account.getPaidMessagesRevenue#19ba4a67 flags:# parent_peer:flags.0?InputPeer user_id:InputUser = account.PaidMessagesRevenue; account.toggleNoPaidMessagesException#fe2eda76 flags:# refund_charged:flags.0?true require_payment:flags.2?true parent_peer:flags.1?InputPeer user_id:InputUser = Bool; account.setMainProfileTab#5dee78b0 tab:ProfileTab = Bool; +account.initPasskeyRegistration#429547e8 = account.PasskeyRegistrationOptions; +account.registerPasskey#55b41fd6 credential:InputPasskeyCredential = Passkey; +account.getPasskeys#ea1f0c52 = account.Passkeys; +account.deletePasskey#f5b5563f id:string = Bool; users.getUsers#d91a548 id:Vector = Vector; users.getFullUser#b60f5918 id:InputUser = users.UserFull; contacts.getContacts#5dd69e12 hash:long = contacts.Contacts; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index 32fc543c1..04593281e 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -18,6 +18,8 @@ "auth.dropTempAuthKeys", "auth.exportLoginToken", "auth.importLoginToken", + "auth.initPasskeyLogin", + "auth.finishPasskeyLogin", "account.registerDevice", "account.unregisterDevice", "account.updateNotifySettings", @@ -68,6 +70,10 @@ "account.getAccountTTL", "account.setAccountTTL", "account.setMainProfileTab", + "account.initPasskeyRegistration", + "account.registerPasskey", + "account.getPasskeys", + "account.deletePasskey", "users.getUsers", "users.getFullUser", "contacts.getContacts", diff --git a/src/limits.ts b/src/limits.ts index 0f50dfd5d..742ac2d2c 100644 --- a/src/limits.ts +++ b/src/limits.ts @@ -158,4 +158,6 @@ export const DEFAULT_APP_CONFIG: ApiAppConfig = { 'translations.telegram.org', ], typingDraftTtl: 10, + arePasskeysAvailable: true, + passkeysMaxCount: 5, }; diff --git a/src/styles/_common.scss b/src/styles/_common.scss index fe9eed1d4..5739d45dd 100644 --- a/src/styles/_common.scss +++ b/src/styles/_common.scss @@ -274,3 +274,9 @@ .peer-color-count-3 { @include mixins.peer-gradient(--bar-gradient, 3); } + +.flex-column-centered { + display: flex; + flex-direction: column; + align-items: center; +} diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index ffc1eeb8c..531f3b940 100644 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -102,6 +102,7 @@ $color-message-story-mention-to: #74bcff; --color-interactive-buffered: rgba(var(--color-text-secondary-rgb), 0.25); // Overlays underlying inactive element --color-interactive-element-hover: rgba(var(--color-text-secondary-rgb), 0.08); --color-composer-button: #{$color-text-secondary}CC; + --color-toast-background: #202020CC; --color-voice-transcribe-button: #e8f3ff; --color-voice-transcribe-button-own: #cceebf; @@ -215,6 +216,7 @@ $color-message-story-mention-to: #74bcff; --border-radius-button: 1rem; --border-radius-button-tiny: 0.875rem; --border-radius-modal: 2rem; + --border-radius-toast: 1rem; --border-radius-default: 0.75rem; --border-radius-default-small: 0.625rem; --border-radius-default-tiny: 0.375rem; diff --git a/src/styles/themes.json b/src/styles/themes.json index 7cd1a0cd1..48fed7078 100644 --- a/src/styles/themes.json +++ b/src/styles/themes.json @@ -73,5 +73,6 @@ "--color-chat-username": ["#3C7EB0", "#E9EEF4"], "--color-borders-read-story": ["#C4C9CC", "#737373"], "--color-background-menu-separator": ["#0000001a", "#ffffff1a"], - "--color-hover-overlay": ["#00000006", "#ffffff06"] + "--color-hover-overlay": ["#00000006", "#ffffff06"], + "--color-toast-background": ["#202020CC", "#000000CC"] } diff --git a/src/types/index.ts b/src/types/index.ts index f5773882a..7c485d217 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -262,6 +262,7 @@ export enum SettingsScreens { CustomEmoji, DoNotTranslate, FoldersShare, + Passkeys, } export type StickerSetOrReactionsSetOrRecent = Pick { 'StarGiftPriceDecreaseTimer': { 'timer': V; }; + 'SettingsPasskeyUsedAt': { + 'date': V; + }; + 'SettingsPasskeysFooter': { + 'link': V; + }; 'UnconfirmedAuthDeniedMessage': { 'location': V; }; diff --git a/src/util/browser/passkeys.ts b/src/util/browser/passkeys.ts new file mode 100644 index 000000000..fcf9c60cd --- /dev/null +++ b/src/util/browser/passkeys.ts @@ -0,0 +1,17 @@ +import type { ApiPasskeyOption, ApiPasskeyRegistrationOption } from '../../api/types'; + +export function toCredentialCreationOptions(option: ApiPasskeyRegistrationOption): CredentialCreationOptions { + const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(option.publicKey); + + return { + publicKey, + }; +} + +export function toCredentialRequestOptions(option: ApiPasskeyOption): CredentialRequestOptions { + const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(option.publicKey); + + return { + publicKey, + }; +} diff --git a/src/util/browser/windowEnvironment.ts b/src/util/browser/windowEnvironment.ts index f0976d202..ab97c54f7 100644 --- a/src/util/browser/windowEnvironment.ts +++ b/src/util/browser/windowEnvironment.ts @@ -114,6 +114,8 @@ export const IS_TRANSLATION_SUPPORTED = !IS_TEST; export const IS_TRANSLATION_DETECTOR_SUPPORTED = 'LanguageDetector' in window; export const IS_VIEW_TRANSITION_SUPPORTED = CSS.supports('view-transition-class: test') && !IS_FIREFOX; // https://bugzilla.mozilla.org/show_bug.cgi?id=1994547 +export const IS_WEBAUTHN_SUPPORTED = navigator.credentials && window.PublicKeyCredential + && 'parseCreationOptionsFromJSON' in PublicKeyCredential; export const MESSAGE_LIST_SENSITIVE_AREA = 750; diff --git a/src/util/encoding/base64.ts b/src/util/encoding/base64.ts new file mode 100644 index 000000000..d44e7e129 --- /dev/null +++ b/src/util/encoding/base64.ts @@ -0,0 +1,16 @@ +export function base64UrlToBase64(base64Url: string): string { + const base64Encoded = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + const padding = base64Url.length % 4 === 0 ? '' : '='.repeat(4 - (base64Url.length % 4)); + const base64WithPadding = base64Encoded + padding; + return base64WithPadding; +} + +export function base64UrlToBuffer(base64Url: string): Buffer { + const base64 = base64UrlToBase64(base64Url); + return Buffer.from(base64, 'base64'); +} + +export function base64UrlToString(base64Url: string): string { + const buffer = base64UrlToBuffer(base64Url); + return buffer.toString('utf-8'); +} diff --git a/src/util/fallbackLangPack.ts b/src/util/fallbackLangPack.ts index 06a781008..50e620dea 100644 --- a/src/util/fallbackLangPack.ts +++ b/src/util/fallbackLangPack.ts @@ -253,7 +253,6 @@ export default { oneValue: '%1$d user', otherValue: '%1$d users', }, - PasswordOn: 'On', BlockedUsersInfo: 'Blocked users will not be able to contact you and will not see your Last Seen time.', EnabledPasswordText: 'You have enabled Two-Step verification.\nYou\'ll need the password you set up here to log in to your Telegram account.', ChangePassword: 'Change Password', diff --git a/src/util/init.ts b/src/util/init.ts index bc14544e0..03a6f8bd5 100644 --- a/src/util/init.ts +++ b/src/util/init.ts @@ -19,7 +19,7 @@ export async function initGlobal(force: boolean = false, prevGlobal?: GlobalStat const initial = cloneDeep(INITIAL_GLOBAL_STATE); const cache = await loadCache(initial); let global = cache || initial; - if (IS_MOCKED_CLIENT) global.authState = 'authorizationStateReady'; + if (IS_MOCKED_CLIENT) global.auth.state = 'authorizationStateReady'; const { hasPasscode, isScreenLocked } = global.passcode; if (hasPasscode && !isScreenLocked) { diff --git a/src/util/websync.ts b/src/util/websync.ts index 9003e6239..8a8dced54 100644 --- a/src/util/websync.ts +++ b/src/util/websync.ts @@ -90,9 +90,9 @@ export function startWebsync() { const timeout = WEBSYNC_TIMEOUT - (currentTs - ts); lastTimeout = setTimeout(() => { - const { authState } = getGlobal(); + const { auth } = getGlobal(); - const authed = authState === 'authorizationStateReady' || hasStoredSession(); + const authed = auth.state === 'authorizationStateReady' || hasStoredSession(); forceWebsync(authed); }, Math.max(0, timeout * 1000)); } diff --git a/webpack.config.ts b/webpack.config.ts index c70caccdd..3f2580cba 100644 --- a/webpack.config.ts +++ b/webpack.config.ts @@ -24,6 +24,8 @@ const { HEAD, APP_ENV = 'production', APP_MOCKED_CLIENT = '', + HTTPS_CERT_PATH = '', + HTTPS_KEY_PATH = '', } = process.env; const DEFAULT_APP_TITLE = `Telegram${APP_ENV !== 'production' ? ' Beta' : ''}`; @@ -57,6 +59,17 @@ export default function createConfig( _: any, { mode = 'production' }: { mode: 'none' | 'development' | 'production' }, ): Configuration { + let server: Required['devServer']['server'] = 'http'; + if (HTTPS_CERT_PATH && HTTPS_KEY_PATH) { + server = { + type: 'https', + options: { + key: HTTPS_KEY_PATH, + cert: HTTPS_CERT_PATH, + }, + }; + } + return { mode, entry: './src/index.tsx', @@ -70,6 +83,7 @@ export default function createConfig( client: { overlay: false, }, + server, static: [ { directory: path.resolve(__dirname, 'public'),