From 41c2a17fdcb23d6f9fe54758fbb1c2cf5417b103 Mon Sep 17 00:00:00 2001 From: zubiden <19638254+zubiden@users.noreply.github.com> Date: Mon, 22 Dec 2025 22:53:51 +0100 Subject: [PATCH] Support Passkeys (#6535) --- src/api/gramjs/apiBuilders/appConfig.ts | 4 + src/api/gramjs/apiBuilders/misc.ts | 12 ++ src/api/gramjs/gramjsBuilders/index.ts | 2 +- src/api/gramjs/gramjsBuilders/passkeys.ts | 31 +++ src/api/gramjs/helpers/misc.ts | 1 + src/api/gramjs/methods/auth.ts | 29 ++- src/api/gramjs/methods/client.ts | 15 +- src/api/gramjs/methods/index.ts | 1 + src/api/gramjs/methods/settings.ts | 52 ++++- src/api/types/misc.ts | 19 ++ src/api/types/updates.ts | 16 +- src/assets/localization/fallback.strings | 27 ++- src/assets/localization/initialKeys.ts | 1 + src/assets/localization/initialStrings.ts | 15 +- src/assets/tgs-previews/DuckNothingFound.svg | 2 +- src/assets/tgs-previews/Search.svg | 2 +- src/assets/tgs-previews/settings/Passkeys.svg | 1 + src/assets/tgs/settings/Passkeys.tgs | Bin 0 -> 44400 bytes src/bundles/extra.ts | 2 +- src/components/App.tsx | 18 +- src/components/auth/Auth.tsx | 11 +- src/components/auth/AuthCode.tsx | 33 ++-- src/components/auth/AuthPassword.tsx | 20 +- src/components/auth/AuthPhoneNumber.tsx | 88 ++++----- src/components/auth/AuthQrCode.tsx | 32 ++-- src/components/auth/AuthRegister.tsx | 28 +-- .../{main => common}/Notifications.tsx | 3 +- .../common/helpers/animatedAssets.ts | 4 + src/components/left/LeftColumn.tsx | 1 + src/components/left/settings/Settings.tsx | 10 + .../left/settings/SettingsHeader.tsx | 4 + .../settings/SettingsPasskeys.module.scss | 8 + .../left/settings/SettingsPasskeys.tsx | 180 ++++++++++++++++++ .../left/settings/SettingsPrivacy.tsx | 68 +++++-- src/components/main/Main.tsx | 6 - src/components/main/Notifications.async.tsx | 13 -- src/components/modals/ModalContainer.tsx | 6 +- .../modals/passkey/PasskeyModal.async.tsx | 14 ++ .../modals/passkey/PasskeyModal.module.scss | 4 + .../modals/passkey/PasskeyModal.tsx | 78 ++++++++ src/components/test/Test.tsx | 5 +- src/components/test/TestNoRedundancy.tsx | 6 +- src/components/ui/Notification.scss | 5 +- src/global/actions/api/chats.ts | 2 +- src/global/actions/api/initial.ts | 81 ++++---- src/global/actions/api/settings.ts | 84 +++++++- src/global/actions/api/sync.ts | 6 +- src/global/actions/apiUpdaters/initial.ts | 111 ++++++----- src/global/actions/ui/initial.ts | 54 ++++-- src/global/actions/ui/misc.ts | 33 +--- src/global/actions/ui/settings.ts | 10 + src/global/cache.ts | 12 +- src/global/init.ts | 2 +- src/global/initialState.ts | 4 +- src/global/reducers/auth.ts | 15 ++ src/global/selectors/settings.ts | 2 +- src/global/types/actions.ts | 10 + src/global/types/globalState.ts | 38 ++-- src/global/types/tabState.ts | 2 + src/hooks/useMultiaccountInfo.ts | 2 +- src/index.tsx | 2 +- src/lib/gramjs/Helpers.ts | 2 +- src/lib/gramjs/client/auth.ts | 141 +++++++++++++- src/lib/gramjs/errors/RPCErrorList.ts | 26 +++ src/lib/gramjs/network/MTProtoSender.ts | 15 +- src/lib/gramjs/tl/apiTl.ts | 6 + src/lib/gramjs/tl/static/api.json | 6 + src/limits.ts | 2 + src/styles/_common.scss | 6 + src/styles/_variables.scss | 2 + src/styles/themes.json | 3 +- src/types/index.ts | 1 + src/types/language.d.ts | 31 ++- src/util/browser/passkeys.ts | 17 ++ src/util/browser/windowEnvironment.ts | 2 + src/util/encoding/base64.ts | 16 ++ src/util/fallbackLangPack.ts | 1 - src/util/init.ts | 2 +- src/util/websync.ts | 4 +- webpack.config.ts | 14 ++ 80 files changed, 1301 insertions(+), 343 deletions(-) create mode 100644 src/api/gramjs/gramjsBuilders/passkeys.ts create mode 100644 src/assets/tgs-previews/settings/Passkeys.svg create mode 100644 src/assets/tgs/settings/Passkeys.tgs rename src/components/{main => common}/Notifications.tsx (86%) create mode 100644 src/components/left/settings/SettingsPasskeys.module.scss create mode 100644 src/components/left/settings/SettingsPasskeys.tsx delete mode 100644 src/components/main/Notifications.async.tsx create mode 100644 src/components/modals/passkey/PasskeyModal.async.tsx create mode 100644 src/components/modals/passkey/PasskeyModal.module.scss create mode 100644 src/components/modals/passkey/PasskeyModal.tsx create mode 100644 src/global/reducers/auth.ts create mode 100644 src/util/browser/passkeys.ts create mode 100644 src/util/encoding/base64.ts 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 0000000000000000000000000000000000000000..62e11b25406ed300336133b1f0c5ff79718715bf GIT binary patch literal 44400 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 z%0A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 M0ebyUnEB5G0E!1?{Qv*} literal 0 HcmV?d00001 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'),