Support Passkeys (#6535)

This commit is contained in:
zubiden 2025-12-22 22:53:51 +01:00 committed by Alexander Zinchuk
parent 01d7cf294d
commit 41c2a17fdc
80 changed files with 1301 additions and 343 deletions

View File

@ -120,6 +120,8 @@ export interface GramJsAppConfig extends LimitsConfig {
verify_age_min?: number; verify_age_min?: number;
message_typing_draft_ttl?: number; message_typing_draft_ttl?: number;
contact_note_length_limit?: number; contact_note_length_limit?: number;
settings_display_passkeys?: boolean;
passkeys_account_passkeys_max?: number;
} }
function buildEmojiSounds(appConfig: GramJsAppConfig) { function buildEmojiSounds(appConfig: GramJsAppConfig) {
@ -243,6 +245,8 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp
verifyAgeCountry: appConfig.verify_age_country, verifyAgeCountry: appConfig.verify_age_country,
verifyAgeMin: appConfig.verify_age_min, verifyAgeMin: appConfig.verify_age_min,
typingDraftTtl: appConfig.message_typing_draft_ttl, typingDraftTtl: appConfig.message_typing_draft_ttl,
arePasskeysAvailable: appConfig.settings_display_passkeys,
passkeysMaxCount: appConfig.passkeys_account_passkeys_max,
}; };
return { return {

View File

@ -7,6 +7,7 @@ import type {
ApiCountry, ApiCountry,
ApiLanguage, ApiLanguage,
ApiOldLangString, ApiOldLangString,
ApiPasskey,
ApiPendingSuggestion, ApiPendingSuggestion,
ApiPrivacyKey, ApiPrivacyKey,
ApiPromoData, ApiPromoData,
@ -349,3 +350,14 @@ export function buildApiRestrictionReasons(
{ reason, text, platform }) => { reason, text, platform }) =>
({ 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,
};
}

View File

@ -362,7 +362,7 @@ export function buildInputStory(story: ApiStory | ApiStorySkipped) {
export function generateRandomTimestampedBigInt() { export function generateRandomTimestampedBigInt() {
// 32 bits for timestamp, 32 bits are random // 32 bits for timestamp, 32 bits are random
const buffer = generateRandomBytes(8); const buffer = generateRandomBytes(8);
const timestampBuffer = Buffer.alloc(4); const timestampBuffer = Buffer.allocUnsafe(4);
timestampBuffer.writeUInt32LE(Math.floor(Date.now() / 1000), 0); timestampBuffer.writeUInt32LE(Math.floor(Date.now() / 1000), 0);
buffer.set(timestampBuffer, 4); buffer.set(timestampBuffer, 4);
return readBigIntFromBuffer(buffer, true, true); return readBigIntFromBuffer(buffer, true, true);

View File

@ -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,
});
}

View File

@ -36,6 +36,7 @@ const ERROR_KEYS: Record<string, RegularLangKey> = {
SRP_PASSWORD_CHANGED: 'ErrorPasswordChanged', SRP_PASSWORD_CHANGED: 'ErrorPasswordChanged',
CODE_INVALID: 'ErrorEmailCodeInvalid', CODE_INVALID: 'ErrorEmailCodeInvalid',
PASSWORD_MISSING: 'ErrorPasswordMissing', PASSWORD_MISSING: 'ErrorPasswordMissing',
PASSKEY_CREDENTIAL_NOT_FOUND: 'ErrorPasskeyUnknown',
}; };
export type MessageRepairContext = Pick<GramJs.TypeMessage, 'peerId' | 'id'>; export type MessageRepairContext = Pick<GramJs.TypeMessage, 'peerId' | 'id'>;

View File

@ -1,4 +1,7 @@
import { PasskeyLoginRequestedError, UserAlreadyAuthorizedError } from '../../../lib/gramjs/errors';
import type { import type {
ApiPasskeyOption,
ApiUpdateAuthorizationState, ApiUpdateAuthorizationState,
ApiUpdateAuthorizationStateType, ApiUpdateAuthorizationStateType,
ApiUser, ApiUser,
@ -19,6 +22,13 @@ export function onWebAuthTokenFailed() {
}); });
} }
export function onPasskeyOption(option: ApiPasskeyOption) {
sendApiUpdate({
'@type': 'updatePasskeyOption',
option,
});
}
export function onRequestPhoneNumber() { export function onRequestPhoneNumber() {
sendApiUpdate(buildAuthStateUpdate('authorizationStateWaitPhoneNumber')); sendApiUpdate(buildAuthStateUpdate('authorizationStateWaitPhoneNumber'));
@ -75,11 +85,20 @@ export function onRequestQrCode(qrCode: { token: Buffer; expires: number }) {
} }
export function onAuthError(err: Error) { 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({ sendApiUpdate({
'@type': 'updateAuthorizationError', '@type': 'updateAuthorizationError',
errorKey: messageKey, errorKey: messageKey,
errorCode: errorMessage,
}); });
} }
@ -151,3 +170,11 @@ export function restartAuthWithQr() {
authController.reject(new Error('RESTART_AUTH_WITH_QR')); authController.reject(new Error('RESTART_AUTH_WITH_QR'));
} }
export function restartAuthWithPasskey(credentialJson: PublicKeyCredentialJSON) {
if (!authController.reject) {
return;
}
authController.reject(new PasskeyLoginRequestedError(credentialJson));
}

View File

@ -54,8 +54,16 @@ import {
updateChannelState, updateChannelState,
} from '../updates/updateManager'; } from '../updates/updateManager';
import { import {
onAuthError, onAuthReady, onCurrentUserUpdate, onRequestCode, onRequestPassword, onRequestPhoneNumber, onAuthError,
onRequestQrCode, onRequestRegistration, onWebAuthTokenFailed, onAuthReady,
onCurrentUserUpdate,
onPasskeyOption,
onRequestCode,
onRequestPassword,
onRequestPhoneNumber,
onRequestQrCode,
onRequestRegistration,
onWebAuthTokenFailed,
} from './auth'; } from './auth';
import downloadMediaWithClient, { parseMediaUrl } from './media'; import downloadMediaWithClient, { parseMediaUrl } from './media';
@ -84,6 +92,7 @@ export async function init(initialArgs: ApiInitialArgs, onConnected?: NoneToVoid
userAgent, platform, sessionData, isWebmSupported, maxBufferSize, webAuthToken, dcId, userAgent, platform, sessionData, isWebmSupported, maxBufferSize, webAuthToken, dcId,
mockScenario, shouldForceHttpTransport, shouldAllowHttpTransport, mockScenario, shouldForceHttpTransport, shouldAllowHttpTransport,
shouldDebugExportedSenders, langCode, isTestServerRequested, accountIds, shouldDebugExportedSenders, langCode, isTestServerRequested, accountIds,
hasPasskeySupport,
} = initialArgs; } = initialArgs;
const session = new sessions.CallbackSession(sessionData, onSessionUpdate); const session = new sessions.CallbackSession(sessionData, onSessionUpdate);
@ -131,6 +140,7 @@ export async function init(initialArgs: ApiInitialArgs, onConnected?: NoneToVoid
phoneCode: onRequestCode, phoneCode: onRequestCode,
password: onRequestPassword, password: onRequestPassword,
firstAndLastNames: onRequestRegistration, firstAndLastNames: onRequestRegistration,
onPasskeyOption,
qrCode: onRequestQrCode, qrCode: onRequestQrCode,
onError: onAuthError, onError: onAuthError,
initialMethod: platform === 'iOS' || platform === 'Android' ? 'phoneNumber' : 'qrCode', initialMethod: platform === 'iOS' || platform === 'Android' ? 'phoneNumber' : 'qrCode',
@ -139,6 +149,7 @@ export async function init(initialArgs: ApiInitialArgs, onConnected?: NoneToVoid
webAuthTokenFailed: onWebAuthTokenFailed, webAuthTokenFailed: onWebAuthTokenFailed,
mockScenario, mockScenario,
accountIds, accountIds,
hasPasskeySupport,
}, onConnected); }, onConnected);
} catch (err: any) { } catch (err: any) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console

View File

@ -5,6 +5,7 @@ export {
export { export {
provideAuthPhoneNumber, provideAuthCode, provideAuthPassword, provideAuthRegistration, restartAuth, restartAuthWithQr, provideAuthPhoneNumber, provideAuthCode, provideAuthPassword, provideAuthRegistration, restartAuth, restartAuthWithQr,
restartAuthWithPasskey,
} from './auth'; } from './auth';
export { export {

View File

@ -8,6 +8,7 @@ import type {
ApiInputPrivacyRules, ApiInputPrivacyRules,
ApiLanguage, ApiLanguage,
ApiNotifyPeerType, ApiNotifyPeerType,
ApiPasskeyRegistrationOption,
ApiPeerNotifySettings, ApiPeerNotifySettings,
ApiPhoto, ApiPhoto,
ApiPrivacyKey, ApiPrivacyKey,
@ -16,6 +17,7 @@ import type {
import { import {
ACCEPTABLE_USERNAME_ERRORS, ACCEPTABLE_USERNAME_ERRORS,
DEBUG,
LANG_PACK, LANG_PACK,
MUTE_INDEFINITE_TIMESTAMP, MUTE_INDEFINITE_TIMESTAMP,
UNMUTE_TIMESTAMP, UNMUTE_TIMESTAMP,
@ -28,6 +30,7 @@ import { buildApiDisallowedGiftsSettings } from '../apiBuilders/gifts';
import { import {
buildApiCountryList, buildApiCountryList,
buildApiLanguage, buildApiLanguage,
buildApiPasskey,
buildApiSession, buildApiSession,
buildApiTimezone, buildApiTimezone,
buildApiWallpaper, buildApiWallpaper,
@ -44,12 +47,14 @@ import {
import { import {
buildDisallowedGiftsSettings, buildDisallowedGiftsSettings,
buildInputChannel, buildInputChannel,
buildInputPeer, buildInputPhoto, buildInputPeer,
buildInputPhoto,
buildInputPrivacyKey, buildInputPrivacyKey,
buildInputPrivacyRules, buildInputPrivacyRules,
buildInputUser, buildInputUser,
DEFAULT_PRIMITIVES, DEFAULT_PRIMITIVES,
} from '../gramjsBuilders'; } from '../gramjsBuilders';
import { buildInputPasskeyCredential } from '../gramjsBuilders/passkeys';
import { addPhotoToLocalDb } from '../helpers/localDb'; import { addPhotoToLocalDb } from '../helpers/localDb';
import localDb from '../localDb'; import localDb from '../localDb';
import { getClient, invokeRequest, uploadFile } from './client'; import { getClient, invokeRequest, uploadFile } from './client';
@ -766,3 +771,48 @@ export function reorderUsernames({ chatId, accessHash, usernames }: {
order: 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,
});
}

View File

@ -25,8 +25,17 @@ export interface ApiInitialArgs {
langCode: string; langCode: string;
isTestServerRequested?: boolean; isTestServerRequested?: boolean;
accountIds?: string[]; accountIds?: string[];
hasPasskeySupport?: boolean;
} }
export type ApiPasskeyOption = {
publicKey: PublicKeyCredentialRequestOptionsJSON;
};
export type ApiPasskeyRegistrationOption = {
publicKey: PublicKeyCredentialCreationOptionsJSON;
};
export interface ApiOnProgress { export interface ApiOnProgress {
( (
progress: number, // Float between 0 and 1. progress: number, // Float between 0 and 1.
@ -276,6 +285,8 @@ export interface ApiAppConfig {
verifyAgeMin?: number; verifyAgeMin?: number;
typingDraftTtl: number; typingDraftTtl: number;
contactNoteLimit?: number; contactNoteLimit?: number;
arePasskeysAvailable: boolean;
passkeysMaxCount: number;
} }
export interface ApiConfig { export interface ApiConfig {
@ -390,3 +401,11 @@ export interface ApiRestrictionReason {
text: string; text: string;
platform: string; platform: string;
} }
export interface ApiPasskey {
id: string;
name: string;
date: number;
softwareEmojiId?: string;
lastUsageDate?: number;
}

View File

@ -39,6 +39,7 @@ import type {
ApiEmojiInteraction, ApiEmojiInteraction,
ApiError, ApiError,
ApiNotifyPeerType, ApiNotifyPeerType,
ApiPasskeyOption,
ApiPeerNotifySettings, ApiPeerNotifySettings,
ApiSessionData, ApiSessionData,
} from './misc'; } from './misc';
@ -85,6 +86,11 @@ export type ApiUpdateWebAuthTokenFailed = {
'@type': 'updateWebAuthTokenFailed'; '@type': 'updateWebAuthTokenFailed';
}; };
export type ApiUpdatePasskeyOption = {
'@type': 'updatePasskeyOption';
option: ApiPasskeyOption;
};
export type ApiUpdateSession = { export type ApiUpdateSession = {
'@type': 'updateSession'; '@type': 'updateSession';
sessionData?: ApiSessionData; sessionData?: ApiSessionData;
@ -93,6 +99,12 @@ export type ApiUpdateSession = {
export type ApiUpdateAuthorizationError = { export type ApiUpdateAuthorizationError = {
'@type': 'updateAuthorizationError'; '@type': 'updateAuthorizationError';
errorKey: RegularLangFnParameters; errorKey: RegularLangFnParameters;
errorCode?: string;
};
export type ApiUpdateUserAlreadyAuthorized = {
'@type': 'updateUserAlreadyAuthorized';
userId: string;
}; };
export type ApiUpdateConnectionState = { export type ApiUpdateConnectionState = {
@ -877,11 +889,11 @@ export type ApiUpdate = (
ApiUpdateChat | ApiUpdateChatInbox | ApiUpdateChatTypingStatus | ApiUpdateChatFullInfo | ApiUpdatePinnedChatIds | ApiUpdateChat | ApiUpdateChatInbox | ApiUpdateChatTypingStatus | ApiUpdateChatFullInfo | ApiUpdatePinnedChatIds |
ApiUpdateChatMembers | ApiUpdateChatJoin | ApiUpdateChatLeave | ApiUpdateChatPinned | ApiUpdatePinnedMessageIds | ApiUpdateChatMembers | ApiUpdateChatJoin | ApiUpdateChatLeave | ApiUpdateChatPinned | ApiUpdatePinnedMessageIds |
ApiUpdateChatListType | ApiUpdateChatFolder | ApiUpdateChatFoldersOrder | ApiUpdateRecommendedChatFolders | ApiUpdateChatListType | ApiUpdateChatFolder | ApiUpdateChatFoldersOrder | ApiUpdateRecommendedChatFolders |
ApiUpdateNewMessage | ApiUpdateMessage | ApiUpdateThreadInfo | ApiUpdateCommonBoxMessages | ApiUpdateNewMessage | ApiUpdateMessage | ApiUpdateThreadInfo | ApiUpdateCommonBoxMessages | ApiUpdatePasskeyOption |
ApiUpdateDeleteMessages | ApiUpdateMessagePoll | ApiUpdateMessagePollVote | ApiUpdateDeleteHistory | ApiUpdateDeleteMessages | ApiUpdateMessagePoll | ApiUpdateMessagePollVote | ApiUpdateDeleteHistory |
ApiDeleteParticipantHistory | ApiUpdateMessageSendSucceeded | ApiUpdateMessageSendFailed | ApiDeleteParticipantHistory | ApiUpdateMessageSendSucceeded | ApiUpdateMessageSendFailed |
ApiUpdateServiceNotification | ApiDeleteContact | ApiUpdateUser | ApiUpdateUserStatus | ApiUpdateServiceNotification | ApiDeleteContact | ApiUpdateUser | ApiUpdateUserStatus |
ApiUpdateUserFullInfo | ApiUpdateVideoProcessingPending | ApiUpdatePeerSettings | ApiUpdateUserFullInfo | ApiUpdateVideoProcessingPending | ApiUpdatePeerSettings | ApiUpdateUserAlreadyAuthorized |
ApiUpdateAvatar | ApiUpdateMessageImage | ApiUpdateDraftMessage | ApiUpdateScheduledMessageSendFailed | ApiUpdateAvatar | ApiUpdateMessageImage | ApiUpdateDraftMessage | ApiUpdateScheduledMessageSendFailed |
ApiUpdateError | ApiUpdateResetContacts | ApiUpdateStartEmojiInteraction | ApiUpdateError | ApiUpdateResetContacts | ApiUpdateStartEmojiInteraction |
ApiUpdateFavoriteStickers | ApiUpdateStickerSet | ApiUpdateStickerSets | ApiUpdateStickerSetsOrder | ApiUpdateFavoriteStickers | ApiUpdateStickerSet | ApiUpdateStickerSets | ApiUpdateStickerSetsOrder |

View File

@ -175,6 +175,7 @@
"LoginQRHelp2" = "Go to **Settings** > **Devices** > **Link Desktop Device**"; "LoginQRHelp2" = "Go to **Settings** > **Devices** > **Link Desktop Device**";
"LoginQRHelp3" = "Point your phone at this screen to confirm login"; "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"; "YourName" = "Your Name";
"LoginRegisterDesc" = "Enter your name and add a profile photo."; "LoginRegisterDesc" = "Enter your name and add a profile photo.";
"LoginRegisterFirstNamePlaceholder" = "First Name"; "LoginRegisterFirstNamePlaceholder" = "First Name";
@ -519,8 +520,6 @@
"Users_one" = "{count} user"; "Users_one" = "{count} user";
"Users_other" = "{count} users"; "Users_other" = "{count} users";
"PrivacySettingsWebSessions" = "Active Websites"; "PrivacySettingsWebSessions" = "Active Websites";
"PasswordOn" = "On";
"PasswordOff" = "Off";
"PrivacyTitle" = "Privacy"; "PrivacyTitle" = "Privacy";
"PrivacyPhoneTitle" = "Who can see my phone number?"; "PrivacyPhoneTitle" = "Who can see my phone number?";
"LastSeenTitle" = "Who can see your Last Seen time?"; "LastSeenTitle" = "Who can see your Last Seen time?";
@ -717,6 +716,7 @@
"ErrorNewSaltInvalid" = "Error validating password, please try again"; "ErrorNewSaltInvalid" = "Error validating password, please try again";
"ErrorPasswordChanged" = "Password has been changed, please try again"; "ErrorPasswordChanged" = "Password has been changed, please try again";
"ErrorPasswordMissing" = "You must set 2FA password to use this feature"; "ErrorPasswordMissing" = "You must set 2FA password to use this feature";
"ErrorPasskeyUnknown" = "This passkey is not assigned to any account";
"ErrorUnspecified" = "Error"; "ErrorUnspecified" = "Error";
"NoStickers" = "No stickers yet"; "NoStickers" = "No stickers yet";
"ClearRecentEmoji" = "Clear recent emoji?"; "ClearRecentEmoji" = "Clear recent emoji?";
@ -2406,6 +2406,29 @@
"StarGiftUpgradeCostModalTitle" = "Upgrade Cost"; "StarGiftUpgradeCostModalTitle" = "Upgrade Cost";
"StarGiftUpgradeCostHint" = "Users who upgrade their gifts first get collectibles with shorter numbers."; "StarGiftUpgradeCostHint" = "Users who upgrade their gifts first get collectibles with shorter numbers.";
"StarGiftPriceDecreaseTimer" = "Price decreases in {timer}"; "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"; "UnconfirmedAuthDeniedTitle" = "New Login Prevented";
"UnconfirmedAuthDeniedMessage" = "We have terminated the login attempt from {location}."; "UnconfirmedAuthDeniedMessage" = "We have terminated the login attempt from {location}.";
"UnconfirmedAuthTitle" = "Someone just got access to your messages!"; "UnconfirmedAuthTitle" = "Someone just got access to your messages!";

View File

@ -16,6 +16,7 @@ const INITIAL_KEYS: LangKey[] = [
'LoginQRHelp2', 'LoginQRHelp2',
'LoginQRHelp3', 'LoginQRHelp3',
'LoginQRCancel', 'LoginQRCancel',
'LoginPasskey',
'YourName', 'YourName',
'LoginRegisterDesc', 'LoginRegisterDesc',
'LoginRegisterFirstNamePlaceholder', 'LoginRegisterFirstNamePlaceholder',

View File

@ -18,7 +18,8 @@ export default {
"LoginQRHelp1": "Open Telegram on your phone", "LoginQRHelp1": "Open Telegram on your phone",
"LoginQRHelp2": "Go to **Settings** > **Devices** > **Link Desktop Device**", "LoginQRHelp2": "Go to **Settings** > **Devices** > **Link Desktop Device**",
"LoginQRHelp3": "Point your phone at this screen to confirm login", "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", "YourName": "Your Name",
"LoginRegisterDesc": "Enter your name and add a profile photo.", "LoginRegisterDesc": "Enter your name and add a profile photo.",
"LoginRegisterFirstNamePlaceholder": "First Name", "LoginRegisterFirstNamePlaceholder": "First Name",
@ -26,12 +27,12 @@ export default {
"LoginSelectCountryTitle": "Country", "LoginSelectCountryTitle": "Country",
"CountryNone": "Country not found", "CountryNone": "Country not found",
"PleaseEnterPassword": "Enter your new password", "PleaseEnterPassword": "Enter your new password",
"ErrorPhoneNumberInvalid": "Invalid phone number, please try again.", "ErrorPhoneNumberInvalid": "Invalid phone number, please try again",
"ErrorCodeInvalid": "Invalid code, please try again.", "ErrorCodeInvalid": "Invalid code, please try again",
"ErrorIncorrectPassword": "Invalid password, please try again.", "ErrorIncorrectPassword": "Invalid password, please try again",
"ErrorPasswordFlood": "Too many attempts, please try again later.", "ErrorPasswordFlood": "Too many attempts, please try again later",
"ErrorPhoneBanned": "This phone number is banned.", "ErrorPhoneBanned": "This phone number is banned",
"ErrorFloodTime": "Too many attempts, please try again in {time}.", "ErrorFloodTime": "Too many attempts, please try again in {time}",
"ErrorUnexpected": "Unexpected error", "ErrorUnexpected": "Unexpected error",
"ErrorUnexpectedMessage": "Unexpected error: {error}" "ErrorUnexpectedMessage": "Unexpected error: {error}"
} as Record<LangKey, LangPackStringValue>; } as Record<LangKey, LangPackStringValue>;

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

View File

@ -5,7 +5,6 @@ export { default as ForwardRecipientPicker } from '../components/main/ForwardRec
export { default as DraftRecipientPicker } from '../components/main/DraftRecipientPicker'; export { default as DraftRecipientPicker } from '../components/main/DraftRecipientPicker';
export { default as AttachBotRecipientPicker } from '../components/main/AttachBotRecipientPicker'; export { default as AttachBotRecipientPicker } from '../components/main/AttachBotRecipientPicker';
export { default as Dialogs } from '../components/main/Dialogs'; 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 SafeLinkModal } from '../components/main/SafeLinkModal';
export { default as MapModal } from '../components/modals/map/MapModal'; export { default as MapModal } from '../components/modals/map/MapModal';
export { default as UrlAuthModal } from '../components/modals/urlAuth/UrlAuthModal'; 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 AgeVerificationModal } from '../components/modals/ageVerification/AgeVerificationModal';
export { default as ChatlistModal } from '../components/modals/chatlist/ChatlistModal'; export { default as ChatlistModal } from '../components/modals/chatlist/ChatlistModal';
export { default as ChatInviteModal } from '../components/modals/chatInvite/ChatInviteModal'; 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 BirthdaySetupModal } from '../components/modals/birthday/BirthdaySetupModal';
export { default as AboutAdsModal } from '../components/modals/aboutAds/AboutAdsModal'; export { default as AboutAdsModal } from '../components/modals/aboutAds/AboutAdsModal';

View File

@ -1,4 +1,3 @@
import type { FC } from '../lib/teact/teact';
import { useEffect, useLayoutEffect } from '../lib/teact/teact'; import { useEffect, useLayoutEffect } from '../lib/teact/teact';
import { withGlobal } from '../global'; import { withGlobal } from '../global';
@ -22,11 +21,12 @@ import { updateSizes } from '../util/windowSize';
import useTauriDrag from '../hooks/tauri/useTauriDrag'; import useTauriDrag from '../hooks/tauri/useTauriDrag';
import useAppLayout from '../hooks/useAppLayout'; import useAppLayout from '../hooks/useAppLayout';
import usePrevious from '../hooks/usePrevious'; import usePrevious from '../hooks/usePrevious';
import { useSignalEffect } from '../hooks/useSignalEffect.ts'; import { useSignalEffect } from '../hooks/useSignalEffect';
import { getIsInBackground } from '../hooks/window/useBackgroundMode.ts'; import { getIsInBackground } from '../hooks/window/useBackgroundMode';
// import Test from './test/TestLocale'; // import Test from './test/TestLocale';
import Auth from './auth/Auth'; import Auth from './auth/Auth';
import Notifications from './common/Notifications';
import UiLoader from './common/UiLoader'; import UiLoader from './common/UiLoader';
import AppInactive from './main/AppInactive'; import AppInactive from './main/AppInactive';
import LockScreen from './main/LockScreen.async'; import LockScreen from './main/LockScreen.async';
@ -36,7 +36,7 @@ import Transition from './ui/Transition';
import styles from './App.module.scss'; import styles from './App.module.scss';
type StateProps = { type StateProps = {
authState: GlobalState['authState']; authState: GlobalState['auth']['state'];
isScreenLocked?: boolean; isScreenLocked?: boolean;
hasPasscode?: boolean; hasPasscode?: boolean;
inactiveReason?: 'auth' | 'otherClient'; 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 ACTIVE_PAGE_TITLE = IS_TAURI ? PAGE_TITLE_TAURI : PAGE_TITLE;
const INACTIVE_PAGE_TITLE = `${ACTIVE_PAGE_TITLE} ${INACTIVE_MARKER}`; const INACTIVE_PAGE_TITLE = `${ACTIVE_PAGE_TITLE} ${INACTIVE_MARKER}`;
const App: FC<StateProps> = ({ const App = ({
authState, authState,
isScreenLocked, isScreenLocked,
hasPasscode, hasPasscode,
@ -66,7 +66,7 @@ const App: FC<StateProps> = ({
isTestServer, isTestServer,
theme, theme,
actionMessageBg, actionMessageBg,
}) => { }: StateProps) => {
const { isMobile } = useAppLayout(); const { isMobile } = useAppLayout();
const isMobileOs = PLATFORM_ENV === 'iOS' || PLATFORM_ENV === 'Android'; const isMobileOs = PLATFORM_ENV === 'iOS' || PLATFORM_ENV === 'Android';
@ -257,18 +257,20 @@ const App: FC<StateProps> = ({
{renderContent} {renderContent}
</Transition> </Transition>
{activeKey === AppScreens.auth && isTestServer && <div className="test-server-badge">Test server</div>} {activeKey === AppScreens.auth && isTestServer && <div className="test-server-badge">Test server</div>}
<Notifications />
</UiLoader> </UiLoader>
); );
}; };
export default withGlobal( export default withGlobal(
(global): Complete<StateProps> => { (global): Complete<StateProps> => {
const { state: authState, hasWebAuthTokenFailed, hasWebAuthTokenPasswordRequired } = global.auth;
return { return {
authState: global.authState, authState,
isScreenLocked: global.passcode?.isScreenLocked, isScreenLocked: global.passcode?.isScreenLocked,
hasPasscode: global.passcode?.hasPasscode, hasPasscode: global.passcode?.hasPasscode,
inactiveReason: selectTabState(global).inactiveReason, inactiveReason: selectTabState(global).inactiveReason,
hasWebAuthTokenFailed: global.hasWebAuthTokenFailed || global.hasWebAuthTokenPasswordRequired, hasWebAuthTokenFailed: hasWebAuthTokenFailed || hasWebAuthTokenPasswordRequired,
theme: selectTheme(global), theme: selectTheme(global),
isTestServer: global.config?.isTestServer, isTestServer: global.config?.isTestServer,
actionMessageBg: selectActionMessageBg(global), actionMessageBg: selectActionMessageBg(global),

View File

@ -1,6 +1,5 @@
import '../../global/actions/initial'; import '../../global/actions/initial';
import type { FC } from '../../lib/teact/teact';
import { memo } from '../../lib/teact/teact'; import { memo } from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global'; import { getActions, withGlobal } from '../../global';
@ -21,11 +20,13 @@ import AuthRegister from './AuthRegister.async';
import './Auth.scss'; import './Auth.scss';
type StateProps = Pick<GlobalState, 'authState'>; type StateProps = {
authState: GlobalState['auth']['state'];
};
const Auth: FC<StateProps> = ({ const Auth = ({
authState, authState,
}) => { }: StateProps) => {
const { const {
returnToAuthPhoneNumber, goToAuthQrCode, returnToAuthPhoneNumber, goToAuthQrCode,
} = getActions(); } = getActions();
@ -101,7 +102,7 @@ const Auth: FC<StateProps> = ({
export default memo(withGlobal( export default memo(withGlobal(
(global): Complete<StateProps> => { (global): Complete<StateProps> => {
return { return {
authState: global.authState, authState: global.auth.state,
}; };
}, },
)(Auth)); )(Auth));

View File

@ -1,5 +1,3 @@
import type { FormEvent } from 'react';
import type { FC } from '../../lib/teact/teact';
import { import {
memo, useCallback, useEffect, useRef, useState, memo, useCallback, useEffect, useRef, useState,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
@ -18,22 +16,23 @@ import TrackingMonkey from '../common/TrackingMonkey';
import InputText from '../ui/InputText'; import InputText from '../ui/InputText';
import Loading from '../ui/Loading'; import Loading from '../ui/Loading';
type StateProps = Pick<GlobalState, 'authPhoneNumber' | 'authIsCodeViaApp' | 'authIsLoading' | 'authErrorKey'>; type StateProps = {
auth: GlobalState['auth'];
};
const CODE_LENGTH = 5; const CODE_LENGTH = 5;
const AuthCode: FC<StateProps> = ({ const AuthCode = ({
authPhoneNumber, auth,
authIsCodeViaApp, }: StateProps) => {
authIsLoading,
authErrorKey,
}) => {
const { const {
setAuthCode, setAuthCode,
returnToAuthPhoneNumber, returnToAuthPhoneNumber,
clearAuthErrorKey, clearAuthErrorKey,
} = getActions(); } = getActions();
const { phoneNumber, isCodeViaApp, isLoading, errorKey } = auth;
const lang = useLang(); const lang = useLang();
const inputRef = useRef<HTMLInputElement>(); const inputRef = useRef<HTMLInputElement>();
@ -52,8 +51,8 @@ const AuthCode: FC<StateProps> = ({
onBack: returnToAuthPhoneNumber, onBack: returnToAuthPhoneNumber,
}); });
const onCodeChange = useCallback((e: FormEvent<HTMLInputElement>) => { const onCodeChange = useCallback((e: React.FormEvent<HTMLInputElement>) => {
if (authErrorKey) { if (errorKey) {
clearAuthErrorKey(); clearAuthErrorKey();
} }
@ -81,7 +80,7 @@ const AuthCode: FC<StateProps> = ({
if (target.value.length === CODE_LENGTH) { if (target.value.length === CODE_LENGTH) {
setAuthCode({ code: target.value }); setAuthCode({ code: target.value });
} }
}, [authErrorKey, code, isTracking, setAuthCode]); }, [errorKey, code, isTracking]);
function handleReturnToAuthPhoneNumber() { function handleReturnToAuthPhoneNumber() {
returnToAuthPhoneNumber(); returnToAuthPhoneNumber();
@ -97,7 +96,7 @@ const AuthCode: FC<StateProps> = ({
trackingDirection={trackingDirection} trackingDirection={trackingDirection}
/> />
<h1> <h1>
{authPhoneNumber} {phoneNumber}
<div <div
className="auth-number-edit div-button" className="auth-number-edit div-button"
onClick={handleReturnToAuthPhoneNumber} onClick={handleReturnToAuthPhoneNumber}
@ -110,7 +109,7 @@ const AuthCode: FC<StateProps> = ({
</div> </div>
</h1> </h1>
<p className="note"> <p className="note">
{lang(authIsCodeViaApp ? 'SentAppCode' : 'LoginJustSentSms', undefined, { {lang(isCodeViaApp ? 'SentAppCode' : 'LoginJustSentSms', undefined, {
withNodes: true, withNodes: true,
withMarkdown: true, withMarkdown: true,
})} })}
@ -121,11 +120,11 @@ const AuthCode: FC<StateProps> = ({
label={lang('Code')} label={lang('Code')}
onInput={onCodeChange} onInput={onCodeChange}
value={code} value={code}
error={authErrorKey && lang.withRegular(authErrorKey)} error={errorKey && lang.withRegular(errorKey)}
autoComplete="off" autoComplete="off"
inputMode="numeric" inputMode="numeric"
/> />
{authIsLoading && <Loading />} {isLoading && <Loading />}
</div> </div>
</div> </div>
); );
@ -133,6 +132,6 @@ const AuthCode: FC<StateProps> = ({
export default memo(withGlobal( export default memo(withGlobal(
(global): Complete<StateProps> => ( (global): Complete<StateProps> => (
pick(global, ['authPhoneNumber', 'authIsCodeViaApp', 'authIsLoading', 'authErrorKey']) as Complete<StateProps> pick(global, ['auth'])
), ),
)(AuthCode)); )(AuthCode));

View File

@ -1,4 +1,3 @@
import type { FC } from '../../lib/teact/teact';
import { memo, useCallback, useState } from '../../lib/teact/teact'; import { memo, useCallback, useState } from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global'; import { getActions, withGlobal } from '../../global';
@ -11,12 +10,15 @@ import useLang from '../../hooks/useLang';
import PasswordForm from '../common/PasswordForm'; import PasswordForm from '../common/PasswordForm';
import MonkeyPassword from '../common/PasswordMonkey'; import MonkeyPassword from '../common/PasswordMonkey';
type StateProps = Pick<GlobalState, 'authIsLoading' | 'authErrorKey' | 'authHint'>; type StateProps = {
auth: GlobalState['auth'];
};
const AuthPassword: FC<StateProps> = ({ const AuthPassword = ({
authIsLoading, authErrorKey, authHint, auth,
}) => { }: StateProps) => {
const { setAuthPassword, clearAuthErrorKey } = getActions(); const { setAuthPassword, clearAuthErrorKey } = getActions();
const { isLoading, errorKey, hint } = auth;
const lang = useLang(); const lang = useLang();
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
@ -37,9 +39,9 @@ const AuthPassword: FC<StateProps> = ({
<p className="note">{lang('LoginEnterPasswordDescription')}</p> <p className="note">{lang('LoginEnterPasswordDescription')}</p>
<PasswordForm <PasswordForm
onClearError={clearAuthErrorKey} onClearError={clearAuthErrorKey}
error={authErrorKey && lang.withRegular(authErrorKey)} error={errorKey && lang.withRegular(errorKey)}
hint={authHint} hint={hint}
isLoading={authIsLoading} isLoading={isLoading}
isPasswordVisible={showPassword} isPasswordVisible={showPassword}
onChangePasswordVisibility={handleChangePasswordVisibility} onChangePasswordVisibility={handleChangePasswordVisibility}
onSubmit={handleSubmit} onSubmit={handleSubmit}
@ -51,6 +53,6 @@ const AuthPassword: FC<StateProps> = ({
export default memo(withGlobal( export default memo(withGlobal(
(global): Complete<StateProps> => ( (global): Complete<StateProps> => (
pick(global, ['authIsLoading', 'authErrorKey', 'authHint']) as Complete<StateProps> pick(global, ['auth'])
), ),
)(AuthPassword)); )(AuthPassword));

View File

@ -1,6 +1,3 @@
import type { ChangeEvent } from 'react';
import type { FC } from '../../lib/teact/teact';
import type React from '../../lib/teact/teact';
import { import {
memo, useEffect, useLayoutEffect, useMemo, useRef, useState, memo, useEffect, useLayoutEffect, useMemo, useRef, useState,
} from '../../lib/teact/teact'; } 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 { IS_SAFARI, IS_TOUCH_ENV } from '../../util/browser/windowEnvironment';
import { preloadImage } from '../../util/files'; import { preloadImage } from '../../util/files';
import preloadFonts from '../../util/fonts'; import preloadFonts from '../../util/fonts';
import { pick } from '../../util/iteratees';
import { getAccountSlotUrl } from '../../util/multiaccount'; import { getAccountSlotUrl } from '../../util/multiaccount';
import { oldSetLanguage } from '../../util/oldLangProvider'; import { oldSetLanguage } from '../../util/oldLangProvider';
import { formatPhoneNumber, getCountryCodeByIso, getCountryFromPhoneNumber } from '../../util/phoneNumber'; import { formatPhoneNumber, getCountryCodeByIso, getCountryFromPhoneNumber } from '../../util/phoneNumber';
@ -34,12 +30,9 @@ import CountryCodeInput from './CountryCodeInput';
import monkeyPath from '../../assets/monkey.svg'; import monkeyPath from '../../assets/monkey.svg';
type StateProps = Pick<GlobalState, ( type StateProps = {
'connectionState' | 'authState' | auth: GlobalState['auth'];
'authPhoneNumber' | 'authIsLoading' | connectionState: GlobalState['connectionState'];
'authIsLoadingQrCode' | 'authErrorKey' |
'authRememberMe' | 'authNearestCountry'
)> & {
language?: string; language?: string;
phoneCodeList: ApiCountryCode[]; phoneCodeList: ApiCountryCode[];
isTestServer?: boolean; isTestServer?: boolean;
@ -49,19 +42,13 @@ const MIN_NUMBER_LENGTH = 7;
let isPreloadInitiated = false; let isPreloadInitiated = false;
const AuthPhoneNumber: FC<StateProps> = ({ const AuthPhoneNumber = ({
auth,
connectionState, connectionState,
authState,
authPhoneNumber,
authIsLoading,
authIsLoadingQrCode,
authErrorKey,
authRememberMe,
authNearestCountry,
phoneCodeList, phoneCodeList,
language, language,
isTestServer, isTestServer,
}) => { }: StateProps) => {
const { const {
setAuthPhoneNumber, setAuthPhoneNumber,
setAuthRememberMe, setAuthRememberMe,
@ -70,8 +57,20 @@ const AuthPhoneNumber: FC<StateProps> = ({
clearAuthErrorKey, clearAuthErrorKey,
goToAuthQrCode, goToAuthQrCode,
setSharedSettingOption, setSharedSettingOption,
loginWithPasskey,
} = getActions(); } = getActions();
const {
state,
phoneNumber: authPhoneNumber,
nearestCountry,
isLoading: authIsLoading,
errorKey,
rememberMe,
isLoadingQrCode,
passkeyOption,
} = auth;
const lang = useLang(); const lang = useLang();
const inputRef = useRef<HTMLInputElement>(); const inputRef = useRef<HTMLInputElement>();
const suggestedLanguage = getSuggestedLanguage(); const suggestedLanguage = getSuggestedLanguage();
@ -105,10 +104,10 @@ const AuthPhoneNumber: FC<StateProps> = ({
}, [country]); }, [country]);
useEffect(() => { useEffect(() => {
if (isConnected && !authNearestCountry) { if (isConnected && !nearestCountry) {
loadNearestCountry(); loadNearestCountry();
} }
}, [isConnected, authNearestCountry]); }, [isConnected, nearestCountry]);
useEffect(() => { useEffect(() => {
if (isConnected) { if (isConnected) {
@ -117,10 +116,10 @@ const AuthPhoneNumber: FC<StateProps> = ({
}, [isConnected, language]); }, [isConnected, language]);
useEffect(() => { useEffect(() => {
if (authNearestCountry && phoneCodeList && !country && !isTouched) { if (nearestCountry && phoneCodeList && !country && !isTouched) {
setCountry(getCountryCodeByIso(phoneCodeList, authNearestCountry)); setCountry(getCountryCodeByIso(phoneCodeList, nearestCountry));
} }
}, [country, authNearestCountry, isTouched, phoneCodeList]); }, [country, nearestCountry, isTouched, phoneCodeList]);
const parseFullNumber = useLastCallback((newFullNumber: string) => { const parseFullNumber = useLastCallback((newFullNumber: string) => {
if (!newFullNumber.length) { if (!newFullNumber.length) {
@ -181,8 +180,8 @@ const AuthPhoneNumber: FC<StateProps> = ({
setPhoneNumber(''); setPhoneNumber('');
}); });
const handlePhoneNumberChange = useLastCallback((e: ChangeEvent<HTMLInputElement>) => { const handlePhoneNumberChange = useLastCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (authErrorKey) { if (errorKey) {
clearAuthErrorKey(); clearAuthErrorKey();
} }
@ -209,7 +208,7 @@ const AuthPhoneNumber: FC<StateProps> = ({
parseFullNumber(shouldFixSafariAutoComplete ? `${country.countryCode} ${value}` : value); parseFullNumber(shouldFixSafariAutoComplete ? `${country.countryCode} ${value}` : value);
}); });
const handleKeepSessionChange = useLastCallback((e: ChangeEvent<HTMLInputElement>) => { const handleKeepSessionChange = useLastCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setAuthRememberMe({ value: e.target.checked }); setAuthRememberMe({ value: e.target.checked });
}); });
@ -235,7 +234,11 @@ const AuthPhoneNumber: FC<StateProps> = ({
goToAuthQrCode(); goToAuthQrCode();
}); });
const isAuthReady = authState === 'authorizationStateWaitPhoneNumber'; const handleLoginWithPasskey = useLastCallback(() => {
loginWithPasskey();
});
const isAuthReady = state === 'authorizationStateWaitPhoneNumber';
return ( return (
<div id="auth-phone-number-form" className="custom-scroll"> <div id="auth-phone-number-form" className="custom-scroll">
@ -257,7 +260,7 @@ const AuthPhoneNumber: FC<StateProps> = ({
<CountryCodeInput <CountryCodeInput
id="sign-in-phone-code" id="sign-in-phone-code"
value={country} value={country}
isLoading={!authNearestCountry && !country} isLoading={!nearestCountry && !country}
onChange={handleCountryChange} onChange={handleCountryChange}
/> />
<InputText <InputText
@ -265,7 +268,7 @@ const AuthPhoneNumber: FC<StateProps> = ({
id="sign-in-phone-number" id="sign-in-phone-number"
label={lang('LoginPhonePlaceholder')} label={lang('LoginPhonePlaceholder')}
value={fullNumber} value={fullNumber}
error={authErrorKey && lang.withRegular(authErrorKey)} error={errorKey && lang.withRegular(errorKey)}
inputMode="tel" inputMode="tel"
onChange={handlePhoneNumberChange} onChange={handlePhoneNumberChange}
onPaste={IS_SAFARI ? handlePaste : undefined} onPaste={IS_SAFARI ? handlePaste : undefined}
@ -273,7 +276,7 @@ const AuthPhoneNumber: FC<StateProps> = ({
<Checkbox <Checkbox
id="sign-in-keep-session" id="sign-in-keep-session"
label={lang('AuthKeepSignedIn')} label={lang('AuthKeepSignedIn')}
checked={Boolean(authRememberMe)} checked={Boolean(rememberMe)}
onChange={handleKeepSessionChange} onChange={handleKeepSessionChange}
/> />
{canSubmit && ( {canSubmit && (
@ -295,12 +298,17 @@ const AuthPhoneNumber: FC<StateProps> = ({
className="auth-button" className="auth-button"
isText isText
ripple ripple
isLoading={authIsLoadingQrCode} isLoading={isLoadingQrCode}
onClick={handleGoToAuthQrCode} onClick={handleGoToAuthQrCode}
> >
{lang('LoginQRLogin')} {lang('LoginQRLogin')}
</Button> </Button>
)} )}
{passkeyOption && (
<Button className="auth-button" isText onClick={handleLoginWithPasskey}>
{lang('LoginPasskey')}
</Button>
)}
{suggestedLanguage && suggestedLanguage !== language && continueText && ( {suggestedLanguage && suggestedLanguage !== language && continueText && (
<Button <Button
className="auth-button" className="auth-button"
@ -323,22 +331,16 @@ export default memo(withGlobal(
sharedState: { settings: { language } }, sharedState: { settings: { language } },
countryList: { phoneCodes: phoneCodeList }, countryList: { phoneCodes: phoneCodeList },
config, config,
auth,
connectionState,
} = global; } = global;
return { return {
...pick(global, [ auth,
'connectionState', connectionState,
'authState',
'authPhoneNumber',
'authIsLoading',
'authIsLoadingQrCode',
'authErrorKey',
'authRememberMe',
'authNearestCountry',
]),
language, language,
phoneCodeList, phoneCodeList,
isTestServer: config?.isTestServer, isTestServer: config?.isTestServer,
} as Complete<StateProps>; };
}, },
)(AuthPhoneNumber)); )(AuthPhoneNumber));

View File

@ -28,11 +28,11 @@ import Loading from '../ui/Loading';
import blankUrl from '../../assets/blank.png'; import blankUrl from '../../assets/blank.png';
type StateProps = type StateProps = {
Pick<GlobalState, 'connectionState' | 'authState' | 'authQrCode'> auth: GlobalState['auth'];
& { connectionState: GlobalState['connectionState'];
language?: string; language?: string;
}; };
const DATA_PREFIX = 'tg://login?token='; const DATA_PREFIX = 'tg://login?token=';
const QR_SIZE = 280; const QR_SIZE = 280;
@ -50,15 +50,17 @@ function ensureQrCodeStyling() {
const AuthCode = ({ const AuthCode = ({
connectionState, connectionState,
authState, auth,
authQrCode,
language, language,
}: StateProps) => { }: StateProps) => {
const { const {
returnToAuthPhoneNumber, returnToAuthPhoneNumber,
setSharedSettingOption, setSharedSettingOption,
loginWithPasskey,
} = getActions(); } = getActions();
const { state, qrCode: authQrCode, passkeyOption } = auth;
const suggestedLanguage = getSuggestedLanguage(); const suggestedLanguage = getSuggestedLanguage();
const lang = useLang(); const lang = useLang();
const qrCodeRef = useRef<HTMLDivElement>(); const qrCodeRef = useRef<HTMLDivElement>();
@ -151,7 +153,11 @@ const AuthCode = ({
returnToAuthPhoneNumber(); returnToAuthPhoneNumber();
}); });
const isAuthReady = authState === 'authorizationStateWaitQrCode'; const handleLoginWithPasskey = useLastCallback(() => {
loginWithPasskey();
});
const isAuthReady = state === 'authorizationStateWaitQrCode';
return ( return (
<div id="auth-qr-form" className="custom-scroll"> <div id="auth-qr-form" className="custom-scroll">
@ -198,6 +204,11 @@ const AuthCode = ({
{lang('LoginQRCancel')} {lang('LoginQRCancel')}
</Button> </Button>
)} )}
{passkeyOption && (
<Button className="auth-button" isText onClick={handleLoginWithPasskey}>
{lang('LoginPasskey')}
</Button>
)}
{suggestedLanguage && suggestedLanguage !== language && continueText && ( {suggestedLanguage && suggestedLanguage !== language && continueText && (
<Button className="auth-button" isText isLoading={isLoading} onClick={handleLangChange}> <Button className="auth-button" isText isLoading={isLoading} onClick={handleLangChange}>
{continueText} {continueText}
@ -211,15 +222,14 @@ const AuthCode = ({
export default memo(withGlobal( export default memo(withGlobal(
(global): Complete<StateProps> => { (global): Complete<StateProps> => {
const { const {
connectionState, authState, authQrCode, connectionState, auth,
} = global; } = global;
const { language } = selectSharedSettings(global); const { language } = selectSharedSettings(global);
return { return {
connectionState, connectionState,
authState, auth,
authQrCode,
language, language,
}; };
}, },

View File

@ -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, useCallback, useState } from '../../lib/teact/teact'; import { memo, useCallback, useState } from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global'; import { getActions, withGlobal } from '../../global';
@ -14,12 +11,15 @@ import AvatarEditable from '../ui/AvatarEditable';
import Button from '../ui/Button'; import Button from '../ui/Button';
import InputText from '../ui/InputText'; import InputText from '../ui/InputText';
type StateProps = Pick<GlobalState, 'authIsLoading' | 'authErrorKey'>; type StateProps = {
auth: GlobalState['auth'];
};
const AuthRegister: FC<StateProps> = ({ const AuthRegister = ({
authIsLoading, authErrorKey, auth,
}) => { }: StateProps) => {
const { signUp, clearAuthErrorKey, uploadProfilePhoto } = getActions(); const { signUp, clearAuthErrorKey, uploadProfilePhoto } = getActions();
const { isLoading, errorKey } = auth;
const lang = useLang(); const lang = useLang();
const [isButtonShown, setIsButtonShown] = useState(false); const [isButtonShown, setIsButtonShown] = useState(false);
@ -27,8 +27,8 @@ const AuthRegister: FC<StateProps> = ({
const [firstName, setFirstName] = useState(''); const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState(''); const [lastName, setLastName] = useState('');
const handleFirstNameChange = useCallback((event: ChangeEvent<HTMLInputElement>) => { const handleFirstNameChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
if (authErrorKey) { if (errorKey) {
clearAuthErrorKey(); clearAuthErrorKey();
} }
@ -36,9 +36,9 @@ const AuthRegister: FC<StateProps> = ({
setFirstName(target.value); setFirstName(target.value);
setIsButtonShown(target.value.length > 0); setIsButtonShown(target.value.length > 0);
}, [authErrorKey]); }, [errorKey]);
const handleLastNameChange = useCallback((event: ChangeEvent<HTMLInputElement>) => { const handleLastNameChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const { target } = event; const { target } = event;
setLastName(target.value); setLastName(target.value);
@ -66,7 +66,7 @@ const AuthRegister: FC<StateProps> = ({
label={lang('LoginRegisterFirstNamePlaceholder')} label={lang('LoginRegisterFirstNamePlaceholder')}
onChange={handleFirstNameChange} onChange={handleFirstNameChange}
value={firstName} value={firstName}
error={authErrorKey && lang.withRegular(authErrorKey)} error={errorKey && lang.withRegular(errorKey)}
autoComplete="given-name" autoComplete="given-name"
/> />
<InputText <InputText
@ -77,7 +77,7 @@ const AuthRegister: FC<StateProps> = ({
autoComplete="family-name" autoComplete="family-name"
/> />
{isButtonShown && ( {isButtonShown && (
<Button type="submit" ripple isLoading={authIsLoading}>{lang('Next')}</Button> <Button type="submit" ripple isLoading={isLoading}>{lang('Next')}</Button>
)} )}
</form> </form>
</div> </div>
@ -87,6 +87,6 @@ const AuthRegister: FC<StateProps> = ({
export default memo(withGlobal( export default memo(withGlobal(
(global): Complete<StateProps> => ( (global): Complete<StateProps> => (
pick(global, ['authIsLoading', 'authErrorKey']) as Complete<StateProps> pick(global, ['auth'])
), ),
)(AuthRegister)); )(AuthRegister));

View File

@ -1,4 +1,3 @@
import type { FC } from '../../lib/teact/teact';
import { memo } from '../../lib/teact/teact'; import { memo } from '../../lib/teact/teact';
import { withGlobal } from '../../global'; import { withGlobal } from '../../global';
@ -13,7 +12,7 @@ type StateProps = {
notifications: ApiNotification[]; notifications: ApiNotification[];
}; };
const Notifications: FC<StateProps> = ({ notifications }) => { const Notifications = ({ notifications }: StateProps) => {
if (!notifications.length) { if (!notifications.length) {
return undefined; return undefined;
} }

View File

@ -33,15 +33,18 @@ import FoldersAll from '../../../assets/tgs/settings/FoldersAll.tgs';
import FoldersNew from '../../../assets/tgs/settings/FoldersNew.tgs'; import FoldersNew from '../../../assets/tgs/settings/FoldersNew.tgs';
import FoldersShare from '../../../assets/tgs/settings/FoldersShare.tgs'; import FoldersShare from '../../../assets/tgs/settings/FoldersShare.tgs';
import Lock from '../../../assets/tgs/settings/Lock.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 StarReaction from '../../../assets/tgs/stars/StarReaction.tgs';
import StarReactionEffect from '../../../assets/tgs/stars/StarReactionEffect.tgs'; import StarReactionEffect from '../../../assets/tgs/stars/StarReactionEffect.tgs';
import Unlock from '../../../assets/tgs/Unlock.tgs'; import Unlock from '../../../assets/tgs/Unlock.tgs';
import DuckNothingFoundPreview from '../../../assets/tgs-previews/DuckNothingFound.svg'; import DuckNothingFoundPreview from '../../../assets/tgs-previews/DuckNothingFound.svg';
import SearchPreview from '../../../assets/tgs-previews/Search.svg'; import SearchPreview from '../../../assets/tgs-previews/Search.svg';
import PasskeysPreview from '../../../assets/tgs-previews/settings/Passkeys.svg';
export const LOCAL_TGS_PREVIEW_URLS = { export const LOCAL_TGS_PREVIEW_URLS = {
DuckNothingFound: DuckNothingFoundPreview, DuckNothingFound: DuckNothingFoundPreview,
Search: SearchPreview, Search: SearchPreview,
Passkeys: PasskeysPreview,
}; };
export const LOCAL_TGS_URLS = { export const LOCAL_TGS_URLS = {
@ -82,5 +85,6 @@ export const LOCAL_TGS_URLS = {
Diamond, Diamond,
Search, Search,
DuckNothingFound, DuckNothingFound,
Passkeys,
DuckCake, DuckCake,
}; };

View File

@ -218,6 +218,7 @@ function LeftColumn({
case SettingsScreens.PasscodeDisabled: case SettingsScreens.PasscodeDisabled:
case SettingsScreens.PasscodeEnabled: case SettingsScreens.PasscodeEnabled:
case SettingsScreens.PasscodeCongratulations: case SettingsScreens.PasscodeCongratulations:
case SettingsScreens.Passkeys:
openSettingsScreen({ screen: SettingsScreens.Privacy }); openSettingsScreen({ screen: SettingsScreens.Privacy });
return; return;

View File

@ -31,6 +31,7 @@ import SettingsHeader from './SettingsHeader';
import SettingsLanguage from './SettingsLanguage'; import SettingsLanguage from './SettingsLanguage';
import SettingsMain from './SettingsMain'; import SettingsMain from './SettingsMain';
import SettingsNotifications from './SettingsNotifications'; import SettingsNotifications from './SettingsNotifications';
import SettingsPasskeys from './SettingsPasskeys';
import SettingsPerformance from './SettingsPerformance'; import SettingsPerformance from './SettingsPerformance';
import SettingsPrivacy from './SettingsPrivacy'; import SettingsPrivacy from './SettingsPrivacy';
import SettingsPrivacyBlockedUsers from './SettingsPrivacyBlockedUsers'; import SettingsPrivacyBlockedUsers from './SettingsPrivacyBlockedUsers';
@ -85,6 +86,7 @@ const FOLDERS_SCREENS = [
const PRIVACY_SCREENS = [ const PRIVACY_SCREENS = [
SettingsScreens.PrivacyBlockedUsers, SettingsScreens.PrivacyBlockedUsers,
SettingsScreens.ActiveWebsites, SettingsScreens.ActiveWebsites,
SettingsScreens.Passkeys,
]; ];
const PRIVACY_PHONE_NUMBER_SCREENS = [ const PRIVACY_PHONE_NUMBER_SCREENS = [
@ -486,6 +488,14 @@ const Settings: FC<OwnProps> = ({
/> />
); );
case SettingsScreens.Passkeys:
return (
<SettingsPasskeys
isActive={isScreenActive}
onReset={handleReset}
/>
);
default: default:
return undefined; return undefined;
} }

View File

@ -253,6 +253,10 @@ const SettingsHeader: FC<OwnProps> = ({
)} )}
</h3> </h3>
); );
case SettingsScreens.Passkeys:
return <h3>{lang('SettingsPasskeyTitle')}</h3>;
default: default:
return ( return (
<div className="settings-main-header"> <div className="settings-main-header">

View File

@ -0,0 +1,8 @@
.icon {
--custom-emoji-size: 2rem;
}
.fallbackIcon {
padding-inline: 0.25rem;
font-size: 1.5rem;
}

View File

@ -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<string>();
const canAddPasskey = IS_WEBAUTHN_SUPPORTED && (passkeys?.length ?? 0) < maxPasskeysCount;
const handleCreatePasskey = useLastCallback(() => {
startPasskeyRegistration();
});
const handleOpenPasskeyModal = useLastCallback(() => {
openPasskeyModal();
});
const confirmDeletePasskey = useLastCallback(() => {
if (!deleteModalId) return;
deletePasskey({ id: deleteModalId });
setDeleteModalId(undefined);
});
useEffect(() => {
if (!passkeys || passkeys.length || !isActive) return;
onReset(); // Autoclose when last passkey is deleted
}, [passkeys, onReset, isActive]);
useHistoryBack({
isActive,
onBack: onReset,
});
function renderPasskey(passkey: ApiPasskey) {
const { softwareEmojiId, id, name, date, lastUsageDate } = passkey;
return (
<ListItem
key={id}
ripple
narrow
contextActions={[{
title: lang('Delete'),
icon: 'delete',
destructive: true,
handler: () => {
setDeleteModalId(id);
},
}]}
leftElement={softwareEmojiId ? (
<CustomEmoji
size={ICON_SIZE}
className={buildClassName(styles.icon, 'ListItem-main-icon')}
documentId={softwareEmojiId}
noPlay
/>
) : (
<Icon name="lock" className={buildClassName(styles.fallbackIcon, 'ListItem-main-icon')} />
)}
>
<div className="multiline-item full-size" dir="auto">
<span className="date">{formatPastDatetime(lang, date)}</span>
<span className="title">{name || lang('SettingsPasskeyFallbackTitle')}</span>
{Boolean(lastUsageDate) && (
<span className="subtitle">
{lang('SettingsPasskeyUsedAt', {
date: formatPastDatetime(lang, lastUsageDate),
})}
</span>
)}
</div>
</ListItem>
);
}
return (
<div className="settings-content custom-scroll">
<div className="settings-content-header">
<AnimatedIconWithPreview
tgsUrl={LOCAL_TGS_URLS.Passkeys}
previewUrl={LOCAL_TGS_PREVIEW_URLS.Passkeys}
size={TOP_STICKER_SIZE}
className="settings-content-icon"
/>
<p className="settings-item-description" dir="auto">
{lang('SettingsPasskeyInfo')}
</p>
</div>
<div className="settings-item">
{passkeys?.map(renderPasskey)}
{canAddPasskey && (
<Button
className="settings-button"
color="primary"
iconName="add"
isText
noForcedUpperCase
onClick={handleCreatePasskey}
>
{lang('SettingsPasskeysCreate')}
</Button>
)}
<p className="settings-item-description mt-3" dir="auto">
{lang('SettingsPasskeysFooter', {
link: <Link isPrimary onClick={handleOpenPasskeyModal}>{lang('SettingsPasskeysFooterLink')}</Link>,
}, { withNodes: true })}
</p>
</div>
<ConfirmDialog
isOpen={Boolean(deleteModalId)}
title={lang('PasskeyDeleteTitle')}
textParts={lang('PasskeyDeleteText', undefined, { withNodes: true, renderTextFilters: ['br'] })}
confirmHandler={confirmDeletePasskey}
confirmIsDestructive
confirmLabel={lang('Delete')}
onClose={() => setDeleteModalId(undefined)}
/>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global): Complete<StateProps> => {
return {
passkeys: global.settings.passkeys,
maxPasskeysCount: global.appConfig.passkeysMaxCount,
};
},
)(SettingsPasskeys));

View File

@ -1,5 +1,4 @@
import type { FC } from '../../../lib/teact/teact'; import { memo, useEffect, useMemo } from '../../../lib/teact/teact';
import { memo, useCallback, useEffect, useMemo } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global'; import { getActions, withGlobal } from '../../../global';
import type { ApiPrivacySettings } from '../../../api/types'; import type { ApiPrivacySettings } from '../../../api/types';
@ -47,17 +46,21 @@ type StateProps = {
needAgeVideoVerification?: boolean; needAgeVideoVerification?: boolean;
privacy: GlobalState['settings']['privacy']; privacy: GlobalState['settings']['privacy'];
accountDaysTtl?: number; accountDaysTtl?: number;
passkeyCount?: number;
arePasskeysAvailable?: boolean;
}; };
const DAYS_PER_MONTH = 30; const DAYS_PER_MONTH = 30;
const SettingsPrivacy: FC<OwnProps & StateProps> = ({ const SettingsPrivacy = ({
isActive, isActive,
isCurrentUserPremium, isCurrentUserPremium,
hasPassword, hasPassword,
hasPasscode, hasPasscode,
blockedCount, blockedCount,
webAuthCount, webAuthCount,
passkeyCount,
arePasskeysAvailable,
isSensitiveEnabled, isSensitiveEnabled,
canChangeSensitive, canChangeSensitive,
canDisplayAutoarchiveSetting, canDisplayAutoarchiveSetting,
@ -71,7 +74,7 @@ const SettingsPrivacy: FC<OwnProps & StateProps> = ({
isCurrentUserFrozen, isCurrentUserFrozen,
accountDaysTtl, accountDaysTtl,
onReset, onReset,
}) => { }: OwnProps & StateProps) => {
const { const {
openDeleteAccountModal, openDeleteAccountModal,
loadPrivacySettings, loadPrivacySettings,
@ -84,6 +87,8 @@ const SettingsPrivacy: FC<OwnProps & StateProps> = ({
openSettingsScreen, openSettingsScreen,
loadAccountDaysTtl, loadAccountDaysTtl,
openAgeVerificationModal, openAgeVerificationModal,
loadPasskeys,
openPasskeyModal,
} = getActions(); } = getActions();
useEffect(() => { useEffect(() => {
@ -91,6 +96,7 @@ const SettingsPrivacy: FC<OwnProps & StateProps> = ({
loadBlockedUsers(); loadBlockedUsers();
loadPrivacySettings({}); loadPrivacySettings({});
loadWebAuthorizations(); loadWebAuthorizations();
loadPasskeys();
} }
}, [isCurrentUserFrozen]); }, [isCurrentUserFrozen]);
@ -99,7 +105,7 @@ const SettingsPrivacy: FC<OwnProps & StateProps> = ({
loadGlobalPrivacySettings(); loadGlobalPrivacySettings();
loadAccountDaysTtl(); loadAccountDaysTtl();
} }
}, [isActive, isCurrentUserFrozen, loadGlobalPrivacySettings]); }, [isActive, isCurrentUserFrozen]);
const oldLang = useOldLang(); const oldLang = useOldLang();
const lang = useLang(); const lang = useLang();
@ -109,31 +115,41 @@ const SettingsPrivacy: FC<OwnProps & StateProps> = ({
onBack: onReset, onBack: onReset,
}); });
const handleArchiveAndMuteChange = useCallback((isEnabled: boolean) => { const handleArchiveAndMuteChange = useLastCallback((isEnabled: boolean) => {
updateGlobalPrivacySettings({ updateGlobalPrivacySettings({
shouldArchiveAndMuteNewNonContact: isEnabled, shouldArchiveAndMuteNewNonContact: isEnabled,
}); });
}, [updateGlobalPrivacySettings]); });
const handleChatInTitleChange = useCallback((isChecked: boolean) => { const handleChatInTitleChange = useLastCallback((isChecked: boolean) => {
setSharedSettingOption({ setSharedSettingOption({
canDisplayChatInTitle: isChecked, canDisplayChatInTitle: isChecked,
}); });
}, []); });
const handleUpdateContentSettings = useCallback((isChecked: boolean) => { const handleUpdateContentSettings = useLastCallback((isChecked: boolean) => {
updateContentSettings({ isSensitiveEnabled: isChecked }); updateContentSettings({ isSensitiveEnabled: isChecked });
}, [updateContentSettings]); });
const handleAgeVerification = useCallback(() => { const handleAgeVerification = useLastCallback(() => {
openAgeVerificationModal(); openAgeVerificationModal();
}, [openAgeVerificationModal]); });
const handleOpenDeleteAccountModal = useLastCallback(() => { const handleOpenDeleteAccountModal = useLastCallback(() => {
if (!accountDaysTtl) return; if (!accountDaysTtl) return;
openDeleteAccountModal({ days: accountDaysTtl }); openDeleteAccountModal({ days: accountDaysTtl });
}); });
const handleOpenPasskeys = useLastCallback(() => {
if (!arePasskeysAvailable || passkeyCount === undefined) return;
if (passkeyCount === 0) {
openPasskeyModal();
return;
}
openSettingsScreen({ screen: SettingsScreens.Passkeys });
});
const dayOption = useMemo(() => { const dayOption = useMemo(() => {
if (!accountDaysTtl) return undefined; if (!accountDaysTtl) return undefined;
return getClosestEntry(ACCOUNT_TTL_OPTIONS, accountDaysTtl / DAYS_PER_MONTH).toString(); return getClosestEntry(ACCOUNT_TTL_OPTIONS, accountDaysTtl / DAYS_PER_MONTH).toString();
@ -192,7 +208,7 @@ const SettingsPrivacy: FC<OwnProps & StateProps> = ({
</ListItem> </ListItem>
{canSetPasscode && ( {canSetPasscode && (
<ListItem <ListItem
icon="key" icon="lock"
narrow narrow
onClick={() => openSettingsScreen({ onClick={() => openSettingsScreen({
@ -202,13 +218,13 @@ const SettingsPrivacy: FC<OwnProps & StateProps> = ({
<div className="multiline-item"> <div className="multiline-item">
<span className="title">{oldLang('Passcode')}</span> <span className="title">{oldLang('Passcode')}</span>
<span className="subtitle" dir="auto"> <span className="subtitle" dir="auto">
{oldLang(hasPasscode ? 'PasswordOn' : 'PasswordOff')} {lang(hasPasscode ? 'SettingsItemPrivacyOn' : 'SettingsItemPrivacyOff')}
</span> </span>
</div> </div>
</ListItem> </ListItem>
)} )}
<ListItem <ListItem
icon="lock" icon="admin"
narrow narrow
onClick={() => openSettingsScreen({ onClick={() => openSettingsScreen({
@ -218,10 +234,25 @@ const SettingsPrivacy: FC<OwnProps & StateProps> = ({
<div className="multiline-item"> <div className="multiline-item">
<span className="title">{oldLang('TwoStepVerification')}</span> <span className="title">{oldLang('TwoStepVerification')}</span>
<span className="subtitle" dir="auto"> <span className="subtitle" dir="auto">
{oldLang(hasPassword ? 'PasswordOn' : 'PasswordOff')} {lang(hasPassword ? 'SettingsItemPrivacyOn' : 'SettingsItemPrivacyOff')}
</span> </span>
</div> </div>
</ListItem> </ListItem>
{arePasskeysAvailable && (
<ListItem
icon="key"
narrow
onClick={handleOpenPasskeys}
>
<div className="multiline-item">
<span className="title">{lang('SettingsItemPrivacyPasskeys')}</span>
<span className="subtitle" dir="auto">
{lang(passkeyCount === undefined ? 'Loading'
: passkeyCount > 0 ? 'SettingsItemPrivacyOn' : 'SettingsItemPrivacyOff')}
</span>
</div>
</ListItem>
)}
{webAuthCount > 0 && ( {webAuthCount > 0 && (
<ListItem <ListItem
icon="web" icon="web"
@ -470,6 +501,7 @@ export default memo(withGlobal<OwnProps>(
}, },
privacy, privacy,
accountDaysTtl, accountDaysTtl,
passkeys,
}, },
blocked, blocked,
passcode: { passcode: {
@ -501,6 +533,8 @@ export default memo(withGlobal<OwnProps>(
canSetPasscode: selectCanSetPasscode(global), canSetPasscode: selectCanSetPasscode(global),
isCurrentUserFrozen, isCurrentUserFrozen,
accountDaysTtl, accountDaysTtl,
passkeyCount: passkeys?.length,
arePasskeysAvailable: appConfig.arePasskeysAvailable,
}; };
}, },
)(SettingsPrivacy)); )(SettingsPrivacy));

View File

@ -85,7 +85,6 @@ import ForwardRecipientPicker from './ForwardRecipientPicker.async';
import GameModal from './GameModal'; import GameModal from './GameModal';
import HistoryCalendar from './HistoryCalendar.async'; import HistoryCalendar from './HistoryCalendar.async';
import NewContactModal from './NewContactModal.async'; import NewContactModal from './NewContactModal.async';
import Notifications from './Notifications.async';
import PremiumLimitReachedModal from './premium/common/PremiumLimitReachedModal.async'; import PremiumLimitReachedModal from './premium/common/PremiumLimitReachedModal.async';
import GiveawayModal from './premium/GiveawayModal.async'; import GiveawayModal from './premium/GiveawayModal.async';
import PremiumMainModal from './premium/PremiumMainModal.async'; import PremiumMainModal from './premium/PremiumMainModal.async';
@ -110,7 +109,6 @@ type StateProps = {
isMediaViewerOpen: boolean; isMediaViewerOpen: boolean;
isStoryViewerOpen: boolean; isStoryViewerOpen: boolean;
isForwardModalOpen: boolean; isForwardModalOpen: boolean;
hasNotifications: boolean;
hasDialogs: boolean; hasDialogs: boolean;
safeLinkModalUrl?: string; safeLinkModalUrl?: string;
isHistoryCalendarOpen: boolean; isHistoryCalendarOpen: boolean;
@ -163,7 +161,6 @@ const Main = ({
isMediaViewerOpen, isMediaViewerOpen,
isStoryViewerOpen, isStoryViewerOpen,
isForwardModalOpen, isForwardModalOpen,
hasNotifications,
hasDialogs, hasDialogs,
activeGroupCallId, activeGroupCallId,
safeLinkModalUrl, safeLinkModalUrl,
@ -564,7 +561,6 @@ const Main = ({
<StoryViewer isOpen={isStoryViewerOpen} /> <StoryViewer isOpen={isStoryViewerOpen} />
<ForwardRecipientPicker isOpen={isForwardModalOpen} /> <ForwardRecipientPicker isOpen={isForwardModalOpen} />
<DraftRecipientPicker requestedDraft={requestedDraft} /> <DraftRecipientPicker requestedDraft={requestedDraft} />
<Notifications isOpen={hasNotifications} />
<Dialogs isOpen={hasDialogs} /> <Dialogs isOpen={hasDialogs} />
<AudioPlayer noUi /> <AudioPlayer noUi />
<ModalContainer /> <ModalContainer />
@ -631,7 +627,6 @@ export default memo(withGlobal<OwnProps>(
openedGame, openedGame,
isLeftColumnShown, isLeftColumnShown,
historyCalendarSelectedAt, historyCalendarSelectedAt,
notifications,
dialogs, dialogs,
newContact, newContact,
ratingPhoneCall, ratingPhoneCall,
@ -665,7 +660,6 @@ export default memo(withGlobal<OwnProps>(
isStoryViewerOpen: selectIsStoryViewerOpen(global), isStoryViewerOpen: selectIsStoryViewerOpen(global),
isForwardModalOpen: selectIsForwardModalOpen(global), isForwardModalOpen: selectIsForwardModalOpen(global),
isReactionPickerOpen: selectIsReactionPickerOpen(global), isReactionPickerOpen: selectIsReactionPickerOpen(global),
hasNotifications: Boolean(notifications.length),
hasDialogs: Boolean(dialogs.length), hasDialogs: Boolean(dialogs.length),
safeLinkModalUrl, safeLinkModalUrl,
isHistoryCalendarOpen: Boolean(historyCalendarSelectedAt), isHistoryCalendarOpen: Boolean(historyCalendarSelectedAt),

View File

@ -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 ? <Notifications /> : undefined;
};
export default NotificationsAsync;

View File

@ -1,5 +1,4 @@
import type { FC } from '../../lib/teact/teact'; import { type FC, memo } from '../../lib/teact/teact';
import { memo } from '../../lib/teact/teact';
import { withGlobal } from '../../global'; import { withGlobal } from '../../global';
import type { TabState } from '../../global/types'; import type { TabState } from '../../global/types';
@ -39,6 +38,7 @@ import LocationAccessModal from './locationAccess/LocationAccessModal.async';
import MapModal from './map/MapModal.async'; import MapModal from './map/MapModal.async';
import OneTimeMediaModal from './oneTimeMedia/OneTimeMediaModal.async'; import OneTimeMediaModal from './oneTimeMedia/OneTimeMediaModal.async';
import PaidReactionModal from './paidReaction/PaidReactionModal.async'; import PaidReactionModal from './paidReaction/PaidReactionModal.async';
import PasskeyModal from './passkey/PasskeyModal.async';
import PreparedMessageModal from './preparedMessage/PreparedMessageModal.async'; import PreparedMessageModal from './preparedMessage/PreparedMessageModal.async';
import PriceConfirmModal from './priceConfirm/PriceConfirmModal.async'; import PriceConfirmModal from './priceConfirm/PriceConfirmModal.async';
import ProfileRatingModal from './profileRating/ProfileRatingModal.async'; import ProfileRatingModal from './profileRating/ProfileRatingModal.async';
@ -111,6 +111,7 @@ type ModalKey = keyof Pick<TabState,
'profileRatingModal' | 'profileRatingModal' |
'quickPreview' | 'quickPreview' |
'storyStealthModal' | 'storyStealthModal' |
'isPasskeyModalOpen' |
'birthdaySetupModal' 'birthdaySetupModal'
>; >;
@ -177,6 +178,7 @@ const MODALS: ModalRegistry = {
profileRatingModal: ProfileRatingModal, profileRatingModal: ProfileRatingModal,
quickPreview: QuickPreviewModal, quickPreview: QuickPreviewModal,
storyStealthModal: StealthModeModal, storyStealthModal: StealthModeModal,
isPasskeyModalOpen: PasskeyModal,
birthdaySetupModal: BirthdaySetupModal, birthdaySetupModal: BirthdaySetupModal,
}; };
const MODAL_KEYS = Object.keys(MODALS) as ModalKey[]; const MODAL_KEYS = Object.keys(MODALS) as ModalKey[];

View File

@ -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 ? <PasskeyModal {...props} /> : undefined;
};
export default PasskeyModalAsync;

View File

@ -0,0 +1,4 @@
.title {
margin-top: 1rem;
margin-bottom: 0;
}

View File

@ -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 = (
<div className="flex-column-centered">
<AnimatedIconWithPreview
tgsUrl={LOCAL_TGS_URLS.Passkeys}
previewUrl={LOCAL_TGS_PREVIEW_URLS.Passkeys}
size={TOP_STICKER_SIZE}
/>
<h3 className={styles.title}>{lang('PasskeyModalTitle')}</h3>
<p>{lang('PasskeyModalDescription')}</p>
</div>
);
const listItemData: TableAboutData = [
['key', lang('PasskeyModalFeature1Title'),
lang('PasskeyModalFeature1Description')],
['animals', lang('PasskeyModalFeature2Title'),
lang('PasskeyModalFeature2Description')],
['lock', lang('PasskeyModalFeature3Title'),
lang('PasskeyModalFeature3Description')],
];
return {
header,
listItemData,
};
}, [lang]);
return (
<TableAboutModal
isOpen={Boolean(modal)}
listItemData={modalData.listItemData}
header={modalData.header}
buttonText={lang('PasskeyModalButtonText')}
onButtonClick={handleCreatePasskey}
onClose={handleClose}
/>
);
};
export default memo(PasskeyModal);

View File

@ -7,7 +7,8 @@ import type { GlobalState } from '../../global/types';
import ErrorTest from './ErrorTest'; import ErrorTest from './ErrorTest';
import SubTest from './SubTest'; import SubTest from './SubTest';
type StateProps = Pick<GlobalState, 'authState'> & { type StateProps = {
authState: GlobalState['auth']['state'];
globalRand: number; globalRand: number;
}; };
@ -40,7 +41,7 @@ const Test: FC<StateProps> = ({ authState, globalRand }) => {
export default withGlobal( export default withGlobal(
(global): Complete<StateProps> => { (global): Complete<StateProps> => {
return { return {
authState: global.authState, authState: global.auth.state,
globalRand: Math.random(), globalRand: Math.random(),
}; };
}, },

View File

@ -14,16 +14,16 @@ document.ondblclick = () => {
setGlobal(global); setGlobal(global);
}; };
type AStateProps = Pick<GlobalState, 'authState'> & { type AStateProps = Pick<GlobalState, 'isSyncing'> & {
aValue: number; aValue: number;
}; };
type BStateProps = Pick<GlobalState, 'authState'> & { type BStateProps = Pick<GlobalState, 'isSyncing'> & {
bValue: number; bValue: number;
derivedAValue: number; derivedAValue: number;
}; };
type BOwnProps = Pick<GlobalState, 'authState'> & { type BOwnProps = Pick<GlobalState, 'isSyncing'> & {
aValue: number; aValue: number;
}; };

View File

@ -19,12 +19,13 @@
margin: 0 0.5rem; margin: 0 0.5rem;
padding: 0.9375rem; padding: 0.9375rem;
border-radius: var(--border-radius-default); border-radius: var(--border-radius-toast);
color: #fff; color: #fff;
background-color: rgba(32, 32, 32, 0.8); background-color: var(--color-toast-background);
background-size: 1.5rem; background-size: 1.5rem;
backdrop-filter: blur(0.5rem);
.text-entity-link, .text-entity-link,
.text-entity-link:hover, .text-entity-link:hover,

View File

@ -531,7 +531,7 @@ addActionHandler('loadAllChats', async (global, actions, payload): Promise<void>
global = getGlobal(); global = getGlobal();
if (global.connectionState !== 'connectionStateReady' || global.authState !== 'authorizationStateReady') { if (global.connectionState !== 'connectionStateReady' || global.auth.state !== 'authorizationStateReady') {
return; return;
} }

View File

@ -11,7 +11,9 @@ import {
} from '../../../config'; } from '../../../config';
import { updateAppBadge } from '../../../util/appBadge'; import { updateAppBadge } from '../../../util/appBadge';
import { PASSCODE_IDB_STORE } from '../../../util/browser/idb'; import { PASSCODE_IDB_STORE } from '../../../util/browser/idb';
import { toCredentialRequestOptions } from '../../../util/browser/passkeys';
import { import {
IS_WEBAUTHN_SUPPORTED,
IS_WEBM_SUPPORTED, MAX_BUFFER_SIZE, PLATFORM_ENV, IS_WEBM_SUPPORTED, MAX_BUFFER_SIZE, PLATFORM_ENV,
} from '../../../util/browser/windowEnvironment'; } from '../../../util/browser/windowEnvironment';
import * as cacheApi from '../../../util/cacheApi'; import * as cacheApi from '../../../util/cacheApi';
@ -37,6 +39,7 @@ import {
import { import {
clearGlobalForLockScreen, updateManagementProgress, updatePasscodeSettings, clearGlobalForLockScreen, updateManagementProgress, updatePasscodeSettings,
} from '../../reducers'; } from '../../reducers';
import { updateAuth } from '../../reducers/auth';
import { selectSharedSettings } from '../../selectors/sharedState'; import { selectSharedSettings } from '../../selectors/sharedState';
import { destroySharedStatePort } from '../../shared/sharedStateConnector'; import { destroySharedStatePort } from '../../shared/sharedStateConnector';
@ -74,6 +77,7 @@ addActionHandler('initApi', (global, actions): ActionReturnType => {
langCode: language, langCode: language,
isTestServerRequested: hasTestParam, isTestServerRequested: hasTestParam,
accountIds, accountIds,
hasPasskeySupport: IS_WEBAUTHN_SUPPORTED,
}); });
void setShouldEnableDebugLog(Boolean(shouldCollectDebugLogs)); void setShouldEnableDebugLog(Boolean(shouldCollectDebugLogs));
@ -84,11 +88,10 @@ addActionHandler('setAuthPhoneNumber', (global, actions, payload): ActionReturnT
void callApi('provideAuthPhoneNumber', phoneNumber.replace(/[^\d]/g, '')); void callApi('provideAuthPhoneNumber', phoneNumber.replace(/[^\d]/g, ''));
return { return updateAuth(global, {
...global, isLoading: true,
authIsLoading: true, errorKey: undefined,
authErrorKey: undefined, });
};
}); });
addActionHandler('setAuthCode', (global, actions, payload): ActionReturnType => { addActionHandler('setAuthCode', (global, actions, payload): ActionReturnType => {
@ -96,11 +99,10 @@ addActionHandler('setAuthCode', (global, actions, payload): ActionReturnType =>
void callApi('provideAuthCode', code); void callApi('provideAuthCode', code);
return { return updateAuth(global, {
...global, isLoading: true,
authIsLoading: true, errorKey: undefined,
authErrorKey: undefined, });
};
}); });
addActionHandler('setAuthPassword', (global, actions, payload): ActionReturnType => { addActionHandler('setAuthPassword', (global, actions, payload): ActionReturnType => {
@ -108,11 +110,28 @@ addActionHandler('setAuthPassword', (global, actions, payload): ActionReturnType
void callApi('provideAuthPassword', password); void callApi('provideAuthPassword', password);
return { return updateAuth(global, {
...global, isLoading: true,
authIsLoading: true, errorKey: undefined,
authErrorKey: undefined, });
}; });
addActionHandler('loginWithPasskey', async (global, actions, payload): Promise<void> => {
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<void> => { addActionHandler('uploadProfilePhoto', async (global, actions, payload): Promise<void> => {
@ -139,30 +158,27 @@ addActionHandler('signUp', (global, actions, payload): ActionReturnType => {
void callApi('provideAuthRegistration', { firstName, lastName }); void callApi('provideAuthRegistration', { firstName, lastName });
return { return updateAuth(global, {
...global, isLoading: true,
authIsLoading: true, errorKey: undefined,
authErrorKey: undefined, });
};
}); });
addActionHandler('returnToAuthPhoneNumber', (global): ActionReturnType => { addActionHandler('returnToAuthPhoneNumber', (global): ActionReturnType => {
void callApi('restartAuth'); void callApi('restartAuth');
return { return updateAuth(global, {
...global, errorKey: undefined,
authErrorKey: undefined, });
};
}); });
addActionHandler('goToAuthQrCode', (global): ActionReturnType => { addActionHandler('goToAuthQrCode', (global): ActionReturnType => {
void callApi('restartAuthWithQr'); void callApi('restartAuthWithQr');
return { return updateAuth(global, {
...global, isLoadingQrCode: true,
authIsLoadingQrCode: true, errorKey: undefined,
authErrorKey: undefined, });
};
}); });
addActionHandler('saveSession', (global, actions, payload): ActionReturnType => { addActionHandler('saveSession', (global, actions, payload): ActionReturnType => {
@ -254,10 +270,9 @@ addActionHandler('loadNearestCountry', async (global): Promise<void> => {
const authNearestCountry = await callApi('fetchNearestCountry'); const authNearestCountry = await callApi('fetchNearestCountry');
global = getGlobal(); global = getGlobal();
global = { global = updateAuth(global, {
...global, nearestCountry: authNearestCountry,
authNearestCountry, });
};
setGlobal(global); setGlobal(global);
}); });

View File

@ -2,6 +2,7 @@ import type { ApiPrivacySettings, ApiUsername } from '../../../api/types';
import type { ActionReturnType } from '../../types'; import type { ActionReturnType } from '../../types';
import { import {
ProfileEditProgress, ProfileEditProgress,
SettingsScreens,
UPLOADING_WALLPAPER_SLUG, UPLOADING_WALLPAPER_SLUG,
} from '../../../types'; } from '../../../types';
@ -11,6 +12,7 @@ import {
MUTE_INDEFINITE_TIMESTAMP, MUTE_INDEFINITE_TIMESTAMP,
UNMUTE_TIMESTAMP, UNMUTE_TIMESTAMP,
} from '../../../config'; } from '../../../config';
import { toCredentialCreationOptions } from '../../../util/browser/passkeys';
import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { buildCollectionByKey } from '../../../util/iteratees'; import { buildCollectionByKey } from '../../../util/iteratees';
import { requestPermission, subscribe, unsubscribe } from '../../../util/notifications'; import { requestPermission, subscribe, unsubscribe } from '../../../util/notifications';
@ -19,7 +21,7 @@ import requestActionTimeout from '../../../util/requestActionTimeout';
import { getServerTime } from '../../../util/serverTime'; import { getServerTime } from '../../../util/serverTime';
import { callApi } from '../../../api/gramjs'; import { callApi } from '../../../api/gramjs';
import { buildApiInputPrivacyRules } from '../../helpers'; import { buildApiInputPrivacyRules } from '../../helpers';
import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { addActionHandler, getGlobal, getPromiseActions, setGlobal } from '../../index';
import { import {
addBlockedUser, addNotifyExceptions, deletePeerPhoto, addBlockedUser, addNotifyExceptions, deletePeerPhoto,
removeBlockedUser, replaceSettings, updateChat, removeBlockedUser, replaceSettings, updateChat,
@ -636,9 +638,9 @@ addActionHandler('loadCountryList', async (global, actions, payload): Promise<vo
}); });
addActionHandler('ensureTimeFormat', async (global, actions): Promise<void> => { addActionHandler('ensureTimeFormat', async (global, actions): Promise<void> => {
if (global.authNearestCountry) { if (global.auth.nearestCountry) {
const timeFormat = COUNTRIES_WITH_12H_TIME_FORMAT const timeFormat = COUNTRIES_WITH_12H_TIME_FORMAT
.has(global.authNearestCountry.toUpperCase()) ? '12h' : '24h'; .has(global.auth.nearestCountry.toUpperCase()) ? '12h' : '24h';
actions.setSharedSettingOption({ timeFormat }); actions.setSharedSettingOption({ timeFormat });
setTimeFormat(timeFormat); setTimeFormat(timeFormat);
} }
@ -947,3 +949,79 @@ addActionHandler('sortChatUsernames', async (global, actions, payload): Promise<
setGlobal(global); setGlobal(global);
} }
}); });
addActionHandler('loadPasskeys', async (global): Promise<void> => {
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<void> => {
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<void> => {
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();
});

View File

@ -313,15 +313,15 @@ function loadTopMessages<T extends GlobalState>(global: T, chatId: string, threa
let previousGlobal: GlobalState | undefined; let previousGlobal: GlobalState | undefined;
// RAF can be unreliable when device goes into sleep mode, so sync logic is handled outside any component // RAF can be unreliable when device goes into sleep mode, so sync logic is handled outside any component
addCallback((global: GlobalState) => { addCallback((global: GlobalState) => {
const { connectionState, authState, isSynced } = global; const { connectionState, auth, isSynced } = global;
const { isMasterTab } = selectTabState(global); const { isMasterTab } = selectTabState(global);
if (!isMasterTab || isSynced || (previousGlobal?.connectionState === connectionState if (!isMasterTab || isSynced || (previousGlobal?.connectionState === connectionState
&& previousGlobal?.authState === authState)) { && previousGlobal?.auth.state === auth.state)) {
previousGlobal = global; previousGlobal = global;
return; return;
} }
if (connectionState === 'connectionStateReady' && authState === 'authorizationStateReady') { if (connectionState === 'connectionStateReady' && auth.state === 'authorizationStateReady') {
getActions().sync(); getActions().sync();
} }

View File

@ -3,8 +3,10 @@ import type {
ApiUpdateAuthorizationState, ApiUpdateAuthorizationState,
ApiUpdateConnectionState, ApiUpdateConnectionState,
ApiUpdateCurrentUser, ApiUpdateCurrentUser,
ApiUpdatePasskeyOption,
ApiUpdateServerTimeOffset, ApiUpdateServerTimeOffset,
ApiUpdateSession, ApiUpdateSession,
ApiUpdateUserAlreadyAuthorized,
} from '../../../api/types'; } from '../../../api/types';
import type { LangCode } from '../../../types'; import type { LangCode } from '../../../types';
import type { RequiredGlobalActions } from '../../index'; import type { RequiredGlobalActions } from '../../index';
@ -13,6 +15,7 @@ import type { ActionReturnType, GlobalState } from '../../types';
import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { getShippingError, shouldClosePaymentModal } from '../../../util/getReadableErrorText'; import { getShippingError, shouldClosePaymentModal } from '../../../util/getReadableErrorText';
import { unique } from '../../../util/iteratees'; import { unique } from '../../../util/iteratees';
import { getAccountsInfo, getAccountSlotUrl } from '../../../util/multiaccount';
import { oldSetLanguage } from '../../../util/oldLangProvider'; import { oldSetLanguage } from '../../../util/oldLangProvider';
import { clearWebTokenAuth } from '../../../util/routing'; import { clearWebTokenAuth } from '../../../util/routing';
import { setServerTimeOffset } from '../../../util/serverTime'; import { setServerTimeOffset } from '../../../util/serverTime';
@ -20,9 +23,10 @@ import { updateSessionUserId } from '../../../util/sessions';
import { forceWebsync } from '../../../util/websync'; import { forceWebsync } from '../../../util/websync';
import { isChatChannel, isChatSuperGroup } from '../../helpers'; import { isChatChannel, isChatSuperGroup } from '../../helpers';
import { import {
addActionHandler, getGlobal, setGlobal, addActionHandler, getActions, getGlobal, setGlobal,
} from '../../index'; } from '../../index';
import { updateUser, updateUserFullInfo } from '../../reducers'; import { updateUser, updateUserFullInfo } from '../../reducers';
import { updateAuth } from '../../reducers/auth';
import { updateTabState } from '../../reducers/tabs'; import { updateTabState } from '../../reducers/tabs';
import { selectTabState } from '../../selectors'; import { selectTabState } from '../../selectors';
import { selectSharedSettings } from '../../selectors/sharedState'; import { selectSharedSettings } from '../../selectors/sharedState';
@ -41,10 +45,18 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
onUpdateAuthorizationError(global, update); onUpdateAuthorizationError(global, update);
break; break;
case 'updateUserAlreadyAuthorized':
onUpdateUserAlreadyAuthorized(global, update);
break;
case 'updateWebAuthTokenFailed': case 'updateWebAuthTokenFailed':
onUpdateWebAuthTokenFailed(global); onUpdateWebAuthTokenFailed(global);
break; break;
case 'updatePasskeyOption':
onUpdatePasskeyOption(global, update);
break;
case 'updateConnectionState': case 'updateConnectionState':
onUpdateConnectionState(global, actions, update); onUpdateConnectionState(global, actions, update);
break; break;
@ -116,58 +128,49 @@ function onUpdateApiReady<T extends GlobalState>(global: T) {
} }
function onUpdateAuthorizationState<T extends GlobalState>(global: T, update: ApiUpdateAuthorizationState) { function onUpdateAuthorizationState<T extends GlobalState>(global: T, update: ApiUpdateAuthorizationState) {
global = getGlobal(); const wasAuthReady = global.auth.state === 'authorizationStateReady';
const wasAuthReady = global.authState === 'authorizationStateReady';
const authState = update.authorizationState; const authState = update.authorizationState;
global = { global = updateAuth(global, {
...global, state: authState,
authState, isLoading: false,
authIsLoading: false, });
};
setGlobal(global); setGlobal(global);
global = getGlobal(); global = getGlobal();
switch (authState) { switch (authState) {
case 'authorizationStateLoggingOut': case 'authorizationStateLoggingOut':
void forceWebsync(false); void forceWebsync(false);
global = { global = updateAuth(global, {
...global,
isLoggingOut: true, isLoggingOut: true,
}; });
setGlobal(global); setGlobal(global);
break; break;
case 'authorizationStateWaitCode': case 'authorizationStateWaitCode':
global = { global = updateAuth(global, {
...global, isCodeViaApp: update.isCodeViaApp,
authIsCodeViaApp: update.isCodeViaApp, });
};
setGlobal(global); setGlobal(global);
break; break;
case 'authorizationStateWaitPassword': case 'authorizationStateWaitPassword':
global = { global = updateAuth(global, {
...global, hint: update.hint,
authHint: update.hint, });
};
if (update.noReset) { if (update.noReset) {
global = { global = updateAuth(global, {
...global,
hasWebAuthTokenPasswordRequired: true, hasWebAuthTokenPasswordRequired: true,
}; });
} }
setGlobal(global); setGlobal(global);
break; break;
case 'authorizationStateWaitQrCode': case 'authorizationStateWaitQrCode':
global = { global = updateAuth(global, {
...global, isLoadingQrCode: false,
authIsLoadingQrCode: false, qrCode: update.qrCode,
authQrCode: update.qrCode, });
};
setGlobal(global); setGlobal(global);
break; break;
case 'authorizationStateReady': { case 'authorizationStateReady': {
@ -177,10 +180,9 @@ function onUpdateAuthorizationState<T extends GlobalState>(global: T, update: Ap
void forceWebsync(true); void forceWebsync(true);
global = { global = updateAuth(global, {
...global,
isLoggingOut: false, isLoggingOut: false,
}; });
Object.values(global.byTabId).forEach(({ id: tabId }) => { Object.values(global.byTabId).forEach(({ id: tabId }) => {
global = updateTabState(global, { global = updateTabState(global, {
inactiveReason: undefined, inactiveReason: undefined,
@ -194,22 +196,44 @@ function onUpdateAuthorizationState<T extends GlobalState>(global: T, update: Ap
} }
function onUpdateAuthorizationError<T extends GlobalState>(global: T, update: ApiUpdateAuthorizationError) { function onUpdateAuthorizationError<T extends GlobalState>(global: T, update: ApiUpdateAuthorizationError) {
// TODO: Investigate why TS is not happy with spread for lang related types if (update.errorCode === 'PASSKEY_CREDENTIAL_NOT_FOUND') {
global = { getActions().showNotification({
...global, message: update.errorKey,
}; tabId: getCurrentTabId(),
global.authErrorKey = update.errorKey; });
return;
}
global = updateAuth(global, {
errorKey: update.errorKey,
});
setGlobal(global); setGlobal(global);
} }
function onUpdateUserAlreadyAuthorized<T extends GlobalState>(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<T extends GlobalState>(global: T) { function onUpdateWebAuthTokenFailed<T extends GlobalState>(global: T) {
clearWebTokenAuth(); clearWebTokenAuth();
global = getGlobal();
global = { global = updateAuth(global, {
...global,
hasWebAuthTokenFailed: true, hasWebAuthTokenFailed: true,
}; });
setGlobal(global);
}
function onUpdatePasskeyOption<T extends GlobalState>(global: T, update: ApiUpdatePasskeyOption) {
global = updateAuth(global, {
passkeyOption: update.option,
});
setGlobal(global); setGlobal(global);
} }
@ -218,7 +242,6 @@ function onUpdateConnectionState<T extends GlobalState>(
) { ) {
const { connectionState } = update; const { connectionState } = update;
global = getGlobal();
const tabState = selectTabState(global, getCurrentTabId()); const tabState = selectTabState(global, getCurrentTabId());
if (connectionState === 'connectionStateReady' && tabState.isMasterTab && tabState.multitabNextAction) { if (connectionState === 'connectionStateReady' && tabState.isMasterTab && tabState.multitabNextAction) {
// @ts-ignore // @ts-ignore
@ -258,7 +281,7 @@ function onUpdateConnectionState<T extends GlobalState>(
function onUpdateSession<T extends GlobalState>(global: T, actions: RequiredGlobalActions, update: ApiUpdateSession) { function onUpdateSession<T extends GlobalState>(global: T, actions: RequiredGlobalActions, update: ApiUpdateSession) {
const { sessionData } = update; const { sessionData } = update;
const { authRememberMe, authState } = global; const { rememberMe, state } = global.auth;
const isEmpty = !sessionData || !sessionData.mainDcId; const isEmpty = !sessionData || !sessionData.mainDcId;
const isTest = sessionData?.isTest; const isTest = sessionData?.isTest;
@ -273,7 +296,7 @@ function onUpdateSession<T extends GlobalState>(global: T, actions: RequiredGlob
setGlobal(global); setGlobal(global);
} }
if (!authRememberMe || authState !== 'authorizationStateReady' || isEmpty) { if (!rememberMe || state !== 'authorizationStateReady' || isEmpty) {
return; return;
} }

View File

@ -1,5 +1,6 @@
import { addCallback } from '../../../lib/teact/teactn'; import { addCallback } from '../../../lib/teact/teactn';
import type { ApiNotification } from '../../../api/types';
import type { LangCode } from '../../../types'; import type { LangCode } from '../../../types';
import type { ActionReturnType, GlobalState } from '../../types'; import type { ActionReturnType, GlobalState } from '../../types';
@ -10,6 +11,7 @@ import {
IS_MAC_OS, IS_SAFARI, IS_TOUCH_ENV, IS_WINDOWS, IS_MAC_OS, IS_SAFARI, IS_TOUCH_ENV, IS_WINDOWS,
} from '../../../util/browser/windowEnvironment'; } from '../../../util/browser/windowEnvironment';
import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { getCurrentTabId } from '../../../util/establishMultitabRole';
import generateUniqueId from '../../../util/generateUniqueId';
import { subscribe, unsubscribe } from '../../../util/notifications'; import { subscribe, unsubscribe } from '../../../util/notifications';
import { oldSetLanguage } from '../../../util/oldLangProvider'; import { oldSetLanguage } from '../../../util/oldLangProvider';
import { decryptSessionByCurrentHash } from '../../../util/passcode'; import { decryptSessionByCurrentHash } from '../../../util/passcode';
@ -22,6 +24,7 @@ import { callApi } from '../../../api/gramjs';
import { clearCaching, setupCaching } from '../../cache'; import { clearCaching, setupCaching } from '../../cache';
import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { addActionHandler, getGlobal, setGlobal } from '../../index';
import { updateSharedSettings } from '../../reducers'; import { updateSharedSettings } from '../../reducers';
import { updateAuth } from '../../reducers/auth';
import { updateTabState } from '../../reducers/tabs'; import { updateTabState } from '../../reducers/tabs';
import { import {
selectCanAnimateInterface, selectCanAnimateInterface,
@ -216,24 +219,21 @@ addActionHandler('setIsUiReady', (global, actions, payload): ActionReturnType =>
addActionHandler('setAuthPhoneNumber', (global, actions, payload): ActionReturnType => { addActionHandler('setAuthPhoneNumber', (global, actions, payload): ActionReturnType => {
const { phoneNumber } = payload; const { phoneNumber } = payload;
return { return updateAuth(global, {
...global, phoneNumber,
authPhoneNumber: phoneNumber, });
};
}); });
addActionHandler('setAuthRememberMe', (global, actions, payload): ActionReturnType => { addActionHandler('setAuthRememberMe', (global, actions, payload): ActionReturnType => {
return { return updateAuth(global, {
...global, rememberMe: Boolean(payload.value),
authRememberMe: Boolean(payload.value), });
};
}); });
addActionHandler('clearAuthErrorKey', (global): ActionReturnType => { addActionHandler('clearAuthErrorKey', (global): ActionReturnType => {
return { return updateAuth(global, {
...global, errorKey: undefined,
authErrorKey: undefined, });
};
}); });
addActionHandler('disableHistoryAnimations', (global, actions, payload): ActionReturnType => { addActionHandler('disableHistoryAnimations', (global, actions, payload): ActionReturnType => {
@ -256,3 +256,33 @@ addActionHandler('disableHistoryAnimations', (global, actions, payload): ActionR
}, tabId); }, tabId);
setGlobal(global, { forceSyncOnIOs: true }); 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);
});

View File

@ -1,6 +1,6 @@
import { addCallback } from '../../../lib/teact/teactn'; 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 type { ActionReturnType, GlobalState } from '../../types';
import { import {
@ -12,7 +12,6 @@ import { IS_TAURI } from '../../../util/browser/globalEnvironment';
import { IS_WAVE_TRANSFORM_SUPPORTED } from '../../../util/browser/windowEnvironment'; import { IS_WAVE_TRANSFORM_SUPPORTED } from '../../../util/browser/windowEnvironment';
import { getAllMultitabTokens, getCurrentTabId, reestablishMasterToSelf } from '../../../util/establishMultitabRole'; import { getAllMultitabTokens, getCurrentTabId, reestablishMasterToSelf } from '../../../util/establishMultitabRole';
import { getAllNotificationsCount } from '../../../util/folderManager'; import { getAllNotificationsCount } from '../../../util/folderManager';
import generateUniqueId from '../../../util/generateUniqueId';
import getIsAppUpdateNeeded from '../../../util/getIsAppUpdateNeeded'; import getIsAppUpdateNeeded from '../../../util/getIsAppUpdateNeeded';
import getReadableErrorText from '../../../util/getReadableErrorText'; import getReadableErrorText from '../../../util/getReadableErrorText';
import { compact, unique } from '../../../util/iteratees'; 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 => { addActionHandler('showAllowedMessageTypesNotification', (global, actions, payload): ActionReturnType => {
const { chatId, messageListType, tabId = getCurrentTabId() } = payload; 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 => { addActionHandler('showDialog', (global, actions, payload): ActionReturnType => {
const { data, tabId = getCurrentTabId() } = payload; const { data, tabId = getCurrentTabId() } = payload;

View File

@ -12,6 +12,7 @@ import { applyPerformanceSettings } from '../../../util/perfomanceSettings';
import switchTheme from '../../../util/switchTheme'; import switchTheme from '../../../util/switchTheme';
import { updatePeerColors } from '../../../util/theme'; import { updatePeerColors } from '../../../util/theme';
import { callApi, setShouldEnableDebugLog } from '../../../api/gramjs'; import { callApi, setShouldEnableDebugLog } from '../../../api/gramjs';
import { addTabStateResetterAction } from '../../helpers/meta';
import { import {
addActionHandler, getActions, setGlobal, addActionHandler, getActions, setGlobal,
} from '../../index'; } from '../../index';
@ -226,3 +227,12 @@ addActionHandler('closeShareChatFolderModal', (global, actions, payload): Action
shareFolderScreen: undefined, shareFolderScreen: undefined,
}, tabId); }, tabId);
}); });
addActionHandler('openPasskeyModal', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
isPasskeyModalOpen: true,
}, tabId);
});
addTabStateResetterAction('closePasskeyModal', 'isPasskeyModalOpen');

View File

@ -362,11 +362,16 @@ function unsafeMigrateCache(cached: GlobalState, initialState: GlobalState) {
cached.sharedState.settings.shouldWarnAboutFiles = true; cached.sharedState.settings.shouldWarnAboutFiles = true;
untypedCached.sharedState.settings.shouldWarnAboutSvg = undefined; untypedCached.sharedState.settings.shouldWarnAboutSvg = undefined;
} }
if (!cached.auth) {
cached.auth = initialState.auth;
cached.auth.rememberMe = untypedCached.rememberMe;
}
} }
function updateCache(force?: boolean) { function updateCache(force?: boolean) {
const global = getGlobal(); const global = getGlobal();
if (isRemovingCache || !isCaching || global.isLoggingOut || (!force && getIsHeavyAnimating())) { if (isRemovingCache || !isCaching || global.auth.isLoggingOut || (!force && getIsHeavyAnimating())) {
return; return;
} }
@ -400,10 +405,7 @@ function reduceGlobal<T extends GlobalState>(global: T) {
...pick(global, [ ...pick(global, [
'appConfig', 'appConfig',
'config', 'config',
'authState', 'auth',
'authPhoneNumber',
'authRememberMe',
'authNearestCountry',
'attachMenu', 'attachMenu',
'currentUserId', 'currentUserId',
'contactList', 'contactList',

View File

@ -110,7 +110,7 @@ addActionHandler('init', (global, actions, payload): ActionReturnType => {
const parsedMessageList = parseLocationHash(global.currentUserId); const parsedMessageList = parseLocationHash(global.currentUserId);
if (global.authState !== 'authorizationStateReady' if (global.auth.state !== 'authorizationStateReady'
&& !global.passcode.hasPasscode && !global.passcode.isScreenLocked) { && !global.passcode.hasPasscode && !global.passcode.isScreenLocked) {
Object.values(global.byTabId).forEach(({ id: otherTabId }) => { Object.values(global.byTabId).forEach(({ id: otherTabId }) => {
if (otherTabId === tabId) return; if (otherTabId === tabId) return;

View File

@ -113,7 +113,9 @@ export const INITIAL_GLOBAL_STATE: GlobalState = {
lastPlaybackRate: DEFAULT_PLAYBACK_RATE, lastPlaybackRate: DEFAULT_PLAYBACK_RATE,
}, },
authRememberMe: true, auth: {
rememberMe: true,
},
countryList: { countryList: {
phoneCodes: [], phoneCodes: [],
general: [], general: [],

View File

@ -0,0 +1,15 @@
import type {
GlobalState,
} from '../types';
export function updateAuth<T extends GlobalState>(
global: T, update: Partial<T['auth']>,
): T {
return {
...global,
auth: {
...global.auth,
...update,
},
};
}

View File

@ -22,7 +22,7 @@ export function selectLanguageCode<T extends GlobalState>(global: T) {
export function selectCanSetPasscode<T extends GlobalState>(global: T) { export function selectCanSetPasscode<T extends GlobalState>(global: T) {
// TODO[passcode]: remove this when multiacc passcode is implemented // TODO[passcode]: remove this when multiacc passcode is implemented
const accounts = getAccountsInfo(); 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<T extends GlobalState>(global: T) { export function selectTranslationLanguage<T extends GlobalState>(global: T) {

View File

@ -147,6 +147,7 @@ export interface ActionPayloads {
tabId?: number; tabId?: number;
}; };
goToAuthQrCode: undefined; goToAuthQrCode: undefined;
loginWithPasskey: undefined;
// stickers & GIFs // stickers & GIFs
setStickerSearchQuery: { query?: string } & WithTabId; setStickerSearchQuery: { query?: string } & WithTabId;
@ -250,6 +251,15 @@ export interface ActionPayloads {
notificationSoundVolume?: number; notificationSoundVolume?: number;
}; };
loadLanguages: undefined; loadLanguages: undefined;
loadPasskeys: undefined;
startPasskeyRegistration: WithTabId | undefined;
deletePasskey: {
id: string;
};
openPasskeyModal: WithTabId | undefined;
closePasskeyModal: WithTabId | undefined;
loadPrivacySettings: { loadPrivacySettings: {
skipIfCached?: boolean; skipIfCached?: boolean;
}; };

View File

@ -16,6 +16,8 @@ import type {
ApiMessage, ApiMessage,
ApiNotifyPeerType, ApiNotifyPeerType,
ApiPaidReactionPrivacyType, ApiPaidReactionPrivacyType,
ApiPasskey,
ApiPasskeyOption,
ApiPeerColors, ApiPeerColors,
ApiPeerNotifySettings, ApiPeerNotifySettings,
ApiPeerPhotos, ApiPeerPhotos,
@ -88,8 +90,6 @@ export type GlobalState = {
byId: Record<string, ApiTimezone>; byId: Record<string, ApiTimezone>;
hash: number; hash: number;
}; };
hasWebAuthTokenFailed?: boolean;
hasWebAuthTokenPasswordRequired?: true;
isCacheApiSupported?: boolean; isCacheApiSupported?: boolean;
connectionState?: ApiUpdateConnectionStateType; connectionState?: ApiUpdateConnectionStateType;
currentUserId?: string; currentUserId?: string;
@ -146,20 +146,25 @@ export type GlobalState = {
isLoading?: boolean; isLoading?: boolean;
}; };
// TODO Move to `auth`. auth: {
isLoggingOut?: boolean; isLoggingOut?: boolean;
authState?: ApiUpdateAuthorizationStateType; state?: ApiUpdateAuthorizationStateType;
authPhoneNumber?: string; phoneNumber?: string;
authIsLoading?: boolean; isLoading?: boolean;
authIsLoadingQrCode?: boolean; isLoadingQrCode?: boolean;
authErrorKey?: RegularLangFnParameters; errorKey?: RegularLangFnParameters;
authRememberMe?: boolean; rememberMe?: boolean;
authNearestCountry?: string; nearestCountry?: string;
authIsCodeViaApp?: boolean; isCodeViaApp?: boolean;
authHint?: string; hint?: string;
authQrCode?: { qrCode?: {
token: string; token: string;
expires: number; expires: number;
};
passkeyOption?: ApiPasskeyOption;
hasWebAuthTokenFailed?: true;
hasWebAuthTokenPasswordRequired?: true;
}; };
countryList: { countryList: {
phoneCodes: ApiCountryCode[]; phoneCodes: ApiCountryCode[];
@ -437,6 +442,7 @@ export type GlobalState = {
botVerificationShownPeerIds: string[]; botVerificationShownPeerIds: string[];
themes: Partial<Record<ThemeKey, IThemeSettings>>; themes: Partial<Record<ThemeKey, IThemeSettings>>;
accountDaysTtl: number; accountDaysTtl: number;
passkeys?: ApiPasskey[];
}; };
push?: { push?: {

View File

@ -920,6 +920,8 @@ export type TabState = {
threadId?: ThreadId; threadId?: ThreadId;
}; };
isPasskeyModalOpen?: boolean;
isWaitingForStarGiftUpgrade?: true; isWaitingForStarGiftUpgrade?: true;
isWaitingForStarGiftTransfer?: true; isWaitingForStarGiftTransfer?: true;
insertingPeerIdMention?: string; insertingPeerIdMention?: string;

View File

@ -20,7 +20,7 @@ const REFRESH_INTERVAL = 1000 * 60;
const PREVIEW_SIZE = 72; const PREVIEW_SIZE = 72;
export default function useMultiaccountInfo(currentUser?: ApiUser) { export default function useMultiaccountInfo(currentUser?: ApiUser) {
const isUpdater = Boolean(currentUser) && getGlobal().authRememberMe; const isUpdater = Boolean(currentUser) && getGlobal().auth.rememberMe;
const isSynced = useSelector(selectIsSynced); const isSynced = useSelector(selectIsSynced);
const [accountsInfo, setAccountsInfo] = useState(() => getAccountsInfo()); const [accountsInfo, setAccountsInfo] = useState(() => getAccountsInfo());

View File

@ -78,7 +78,7 @@ async function init() {
getActions() getActions()
.switchMultitabRole({ isMasterTab }, { forceSyncOnIOs: true }); .switchMultitabRole({ isMasterTab }, { forceSyncOnIOs: true });
}); });
const shouldReestablishMasterToSelf = getGlobal().authState !== 'authorizationStateReady'; const shouldReestablishMasterToSelf = getGlobal().auth.state !== 'authorizationStateReady';
establishMultitabRole(shouldReestablishMasterToSelf); establishMultitabRole(shouldReestablishMasterToSelf);
if (DEBUG) { if (DEBUG) {

View File

@ -130,7 +130,7 @@ export async function generateKeyDataFromNonce(
} }
export function convertToLittle(buf: Uint32Array) { 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++) { for (let i = 0; i < buf.length; i++) {
correct.writeUInt32BE(buf[i], i * 4); correct.writeUInt32BE(buf[i], i * 4);

View File

@ -1,10 +1,16 @@
import type { ApiPasskeyOption } from '../../../api/types';
import type TelegramClient from './TelegramClient'; import type TelegramClient from './TelegramClient';
import type { Update } from './TelegramClient'; import type { Update } from './TelegramClient';
import { DEBUG } from '../../../config';
import { base64UrlToString } from '../../../util/encoding/base64';
import { tryParseBigInt } from '../../../util/numbers'; import { tryParseBigInt } from '../../../util/numbers';
import { getServerTime } from '../../../util/serverTime'; import { getServerTime } from '../../../util/serverTime';
import { DEFAULT_PRIMITIVES } from '../../../api/gramjs/gramjsBuilders'; 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 Api from '../tl/api';
import { sleep } from '../Helpers'; import { sleep } from '../Helpers';
@ -14,6 +20,7 @@ import { getDisplayName } from '../Utils';
export interface UserAuthParams { export interface UserAuthParams {
phoneNumber: string | (() => Promise<string>); phoneNumber: string | (() => Promise<string>);
webAuthTokenFailed: () => void; webAuthTokenFailed: () => void;
onPasskeyOption: (passkeyOption: ApiPasskeyOption) => void;
phoneCode: (isCodeViaApp?: boolean) => Promise<string>; phoneCode: (isCodeViaApp?: boolean) => Promise<string>;
password: (hint?: string, noReset?: boolean) => Promise<string>; password: (hint?: string, noReset?: boolean) => Promise<string>;
firstAndLastNames: () => Promise<[string, string?]>; firstAndLastNames: () => Promise<[string, string?]>;
@ -24,6 +31,7 @@ export interface UserAuthParams {
shouldThrowIfUnauthorized?: boolean; shouldThrowIfUnauthorized?: boolean;
webAuthToken?: string; webAuthToken?: string;
accountIds?: string[]; accountIds?: string[];
hasPasskeySupport?: boolean;
mockScenario?: string; mockScenario?: string;
} }
@ -36,7 +44,9 @@ interface ApiCredentials {
apiHash: string; 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( export async function authFlow(
client: TelegramClient, client: TelegramClient,
@ -61,6 +71,8 @@ export function signInUserWithPreferredMethod(
): Promise<Api.TypeUser> { ): Promise<Api.TypeUser> {
const { initialMethod = DEFAULT_INITIAL_METHOD } = authParams; const { initialMethod = DEFAULT_INITIAL_METHOD } = authParams;
refreshPasskeyLoginOption(client, apiCredentials, authParams);
if (initialMethod === 'phoneNumber') { if (initialMethod === 'phoneNumber') {
return signInUser(client, apiCredentials, authParams); return signInUser(client, apiCredentials, authParams);
} else { } 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) { export async function checkAuthorization(client: TelegramClient, shouldThrow = false) {
try { try {
await client.invoke(new Api.updates.GetState()); await client.invoke(new Api.updates.GetState());
@ -114,6 +142,7 @@ async function signInUser(
let phoneNumber; let phoneNumber;
let phoneCodeHash; let phoneCodeHash;
let isCodeViaApp = false; let isCodeViaApp = false;
lastUsedMethod = 'phoneNumber';
while (true) { while (true) {
try { try {
@ -122,7 +151,11 @@ async function signInUser(
phoneNumber = await authParams.phoneNumber(); phoneNumber = await authParams.phoneNumber();
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof Error && err.message === 'RESTART_AUTH_WITH_QR') { 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; throw err;
@ -235,6 +268,8 @@ async function signInUserWithQrCode(
const { apiId, apiHash } = apiCredentials; const { apiId, apiHash } = apiCredentials;
const exceptIds = authParams.accountIds?.map((id) => tryParseBigInt(id)).filter(Boolean) || []; const exceptIds = authParams.accountIds?.map((id) => tryParseBigInt(id)).filter(Boolean) || [];
lastUsedMethod = 'qrCode';
const inputPromise = (async () => { const inputPromise = (async () => {
// eslint-disable-next-line no-constant-condition // eslint-disable-next-line no-constant-condition
while (1) { while (1) {
@ -277,6 +312,10 @@ async function signInUserWithQrCode(
return await signInUser(client, apiCredentials, authParams); return await signInUser(client, apiCredentials, authParams);
} }
if (err instanceof PasskeyLoginRequestedError) {
return await signInUserWithPasskey(client, apiCredentials, authParams, err.credentialJson);
}
throw err; throw err;
} finally { } finally {
isScanningComplete = true; isScanningComplete = true;
@ -315,6 +354,102 @@ async function signInUserWithQrCode(
throw undefined; throw undefined;
} }
export async function obtainPasskeyLoginOption(
client: TelegramClient, apiCredentials: ApiCredentials,
): Promise<ApiPasskeyOption | undefined> {
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<Api.TypeUser> {
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( async function sendCode(
client: TelegramClient, apiCredentials: ApiCredentials, phoneNumber: string, forceSMS = false, client: TelegramClient, apiCredentials: ApiCredentials, phoneNumber: string, forceSMS = false,
): Promise<{ ): Promise<{

View File

@ -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<RegExp, any>([ export const rpcErrorRe = new Map<RegExp, any>([
[/FILE_MIGRATE_(\d+)/, FileMigrateError], [/FILE_MIGRATE_(\d+)/, FileMigrateError],
[/FLOOD_TEST_PHONE_WAIT_(\d+)/, FloodTestPhoneWaitError], [/FLOOD_TEST_PHONE_WAIT_(\d+)/, FloodTestPhoneWaitError],
@ -161,4 +186,5 @@ export const rpcErrorRe = new Map<RegExp, any>([
[/EMAIL_UNCONFIRMED_(\d+)/, EmailUnconfirmedError], [/EMAIL_UNCONFIRMED_(\d+)/, EmailUnconfirmedError],
[/PASSWORD_TOO_FRESH_(\d+)/, PasswordFreshError], [/PASSWORD_TOO_FRESH_(\d+)/, PasswordFreshError],
[/^Timeout$/, TimedOutError], [/^Timeout$/, TimedOutError],
[/PASSKEY_CREDENTIAL_NOT_FOUND/, PasskeyCredentialNotFoundError],
]); ]);

View File

@ -548,8 +548,9 @@ export default class MTProtoSender {
try { try {
await this._fallbackConnection?.send(data); await this._fallbackConnection?.send(data);
} catch (e: any) { } catch (e: any) {
this._log.error(e);
this._log.info('Connection closed while sending data'); this._log.info('Connection closed while sending data');
// eslint-disable-next-line no-console
console.error(e);
this._longPollLoopHandle = undefined; this._longPollLoopHandle = undefined;
this.isSendingLongPoll = false; this.isSendingLongPoll = false;
if (!this.userDisconnected) { if (!this.userDisconnected) {
@ -651,8 +652,9 @@ export default class MTProtoSender {
await this.getConnection()!.send(data); await this.getConnection()!.send(data);
} catch (e: any) { } catch (e: any) {
this.logWithIndex.debug(`Connection closed while sending data ${e}`); this.logWithIndex.debug(`Connection closed while sending data ${e}`);
this._log.error(e);
this._log.info('Connection closed while sending data'); this._log.info('Connection closed while sending data');
// eslint-disable-next-line no-console
console.error(e);
this._sendLoopHandle = undefined; this._sendLoopHandle = undefined;
if (!this.userDisconnected) { if (!this.userDisconnected) {
this.reconnect(); this.reconnect();
@ -694,8 +696,9 @@ export default class MTProtoSender {
// this._log.info('Connection closed while receiving data'); // this._log.info('Connection closed while receiving data');
/** when the server disconnects us we want to reconnect */ /** when the server disconnects us we want to reconnect */
if (!this.userDisconnected) { if (!this.userDisconnected) {
this._log.error(e);
this._log.warn('Connection closed while receiving data'); this._log.warn('Connection closed while receiving data');
// eslint-disable-next-line no-console
console.error(e);
this.reconnect(); this.reconnect();
} }
this._recvLoopHandle = undefined; this._recvLoopHandle = undefined;
@ -731,7 +734,8 @@ export default class MTProtoSender {
return; return;
} else { } else {
this._log.error('Unhandled error while receiving data'); this._log.error('Unhandled error while receiving data');
this._log.error(e); // eslint-disable-next-line no-console
console.error(e);
this.reconnect(); this.reconnect();
this._recvLoopHandle = undefined; this._recvLoopHandle = undefined;
return; return;
@ -750,7 +754,8 @@ export default class MTProtoSender {
} }
} else { } else {
this._log.error('Unhandled error while receiving data'); this._log.error('Unhandled error while receiving data');
this._log.error(e); // eslint-disable-next-line no-console
console.error(e);
} }
} }

View File

@ -1544,6 +1544,8 @@ auth.dropTempAuthKeys#8e48a188 except_auth_keys:Vector<long> = Bool;
auth.exportLoginToken#b7e085fe api_id:int api_hash:string except_ids:Vector<long> = auth.LoginToken; auth.exportLoginToken#b7e085fe api_id:int api_hash:string except_ids:Vector<long> = auth.LoginToken;
auth.importLoginToken#95ac5ce4 token:bytes = 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.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<long> = Bool; account.registerDevice#ec86017a flags:# no_muted:flags.0?true token_type:int token:string app_sandbox:Bool secret:bytes other_uids:Vector<long> = Bool;
account.unregisterDevice#6a0d3206 token_type:int token:string other_uids:Vector<long> = Bool; account.unregisterDevice#6a0d3206 token_type:int token:string other_uids:Vector<long> = Bool;
account.updateNotifySettings#84be5b93 peer:InputNotifyPeer settings:InputPeerNotifySettings = 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.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.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.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<InputUser> = Vector<User>; users.getUsers#d91a548 id:Vector<InputUser> = Vector<User>;
users.getFullUser#b60f5918 id:InputUser = users.UserFull; users.getFullUser#b60f5918 id:InputUser = users.UserFull;
contacts.getContacts#5dd69e12 hash:long = contacts.Contacts; contacts.getContacts#5dd69e12 hash:long = contacts.Contacts;

View File

@ -18,6 +18,8 @@
"auth.dropTempAuthKeys", "auth.dropTempAuthKeys",
"auth.exportLoginToken", "auth.exportLoginToken",
"auth.importLoginToken", "auth.importLoginToken",
"auth.initPasskeyLogin",
"auth.finishPasskeyLogin",
"account.registerDevice", "account.registerDevice",
"account.unregisterDevice", "account.unregisterDevice",
"account.updateNotifySettings", "account.updateNotifySettings",
@ -68,6 +70,10 @@
"account.getAccountTTL", "account.getAccountTTL",
"account.setAccountTTL", "account.setAccountTTL",
"account.setMainProfileTab", "account.setMainProfileTab",
"account.initPasskeyRegistration",
"account.registerPasskey",
"account.getPasskeys",
"account.deletePasskey",
"users.getUsers", "users.getUsers",
"users.getFullUser", "users.getFullUser",
"contacts.getContacts", "contacts.getContacts",

View File

@ -158,4 +158,6 @@ export const DEFAULT_APP_CONFIG: ApiAppConfig = {
'translations.telegram.org', 'translations.telegram.org',
], ],
typingDraftTtl: 10, typingDraftTtl: 10,
arePasskeysAvailable: true,
passkeysMaxCount: 5,
}; };

View File

@ -274,3 +274,9 @@
.peer-color-count-3 { .peer-color-count-3 {
@include mixins.peer-gradient(--bar-gradient, 3); @include mixins.peer-gradient(--bar-gradient, 3);
} }
.flex-column-centered {
display: flex;
flex-direction: column;
align-items: center;
}

View File

@ -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-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-interactive-element-hover: rgba(var(--color-text-secondary-rgb), 0.08);
--color-composer-button: #{$color-text-secondary}CC; --color-composer-button: #{$color-text-secondary}CC;
--color-toast-background: #202020CC;
--color-voice-transcribe-button: #e8f3ff; --color-voice-transcribe-button: #e8f3ff;
--color-voice-transcribe-button-own: #cceebf; --color-voice-transcribe-button-own: #cceebf;
@ -215,6 +216,7 @@ $color-message-story-mention-to: #74bcff;
--border-radius-button: 1rem; --border-radius-button: 1rem;
--border-radius-button-tiny: 0.875rem; --border-radius-button-tiny: 0.875rem;
--border-radius-modal: 2rem; --border-radius-modal: 2rem;
--border-radius-toast: 1rem;
--border-radius-default: 0.75rem; --border-radius-default: 0.75rem;
--border-radius-default-small: 0.625rem; --border-radius-default-small: 0.625rem;
--border-radius-default-tiny: 0.375rem; --border-radius-default-tiny: 0.375rem;

View File

@ -73,5 +73,6 @@
"--color-chat-username": ["#3C7EB0", "#E9EEF4"], "--color-chat-username": ["#3C7EB0", "#E9EEF4"],
"--color-borders-read-story": ["#C4C9CC", "#737373"], "--color-borders-read-story": ["#C4C9CC", "#737373"],
"--color-background-menu-separator": ["#0000001a", "#ffffff1a"], "--color-background-menu-separator": ["#0000001a", "#ffffff1a"],
"--color-hover-overlay": ["#00000006", "#ffffff06"] "--color-hover-overlay": ["#00000006", "#ffffff06"],
"--color-toast-background": ["#202020CC", "#000000CC"]
} }

View File

@ -262,6 +262,7 @@ export enum SettingsScreens {
CustomEmoji, CustomEmoji,
DoNotTranslate, DoNotTranslate,
FoldersShare, FoldersShare,
Passkeys,
} }
export type StickerSetOrReactionsSetOrRecent = Pick<ApiStickerSet, ( export type StickerSetOrReactionsSetOrRecent = Pick<ApiStickerSet, (

View File

@ -157,6 +157,7 @@ export interface LangPair {
'LoginQRHelp2': undefined; 'LoginQRHelp2': undefined;
'LoginQRHelp3': undefined; 'LoginQRHelp3': undefined;
'LoginQRCancel': undefined; 'LoginQRCancel': undefined;
'LoginPasskey': undefined;
'YourName': undefined; 'YourName': undefined;
'LoginRegisterDesc': undefined; 'LoginRegisterDesc': undefined;
'LoginRegisterFirstNamePlaceholder': undefined; 'LoginRegisterFirstNamePlaceholder': undefined;
@ -468,8 +469,6 @@ export interface LangPair {
'P2PContacts': undefined; 'P2PContacts': undefined;
'P2PNobody': undefined; 'P2PNobody': undefined;
'PrivacySettingsWebSessions': undefined; 'PrivacySettingsWebSessions': undefined;
'PasswordOn': undefined;
'PasswordOff': undefined;
'PrivacyTitle': undefined; 'PrivacyTitle': undefined;
'PrivacyPhoneTitle': undefined; 'PrivacyPhoneTitle': undefined;
'LastSeenTitle': undefined; 'LastSeenTitle': undefined;
@ -634,6 +633,7 @@ export interface LangPair {
'ErrorNewSaltInvalid': undefined; 'ErrorNewSaltInvalid': undefined;
'ErrorPasswordChanged': undefined; 'ErrorPasswordChanged': undefined;
'ErrorPasswordMissing': undefined; 'ErrorPasswordMissing': undefined;
'ErrorPasskeyUnknown': undefined;
'ErrorUnspecified': undefined; 'ErrorUnspecified': undefined;
'NoStickers': undefined; 'NoStickers': undefined;
'ClearRecentEmoji': undefined; 'ClearRecentEmoji': undefined;
@ -1788,6 +1788,27 @@ export interface LangPair {
'StarGiftPriceDecreaseInfoLink': undefined; 'StarGiftPriceDecreaseInfoLink': undefined;
'StarGiftUpgradeCostModalTitle': undefined; 'StarGiftUpgradeCostModalTitle': undefined;
'StarGiftUpgradeCostHint': undefined; 'StarGiftUpgradeCostHint': undefined;
'SettingsItemPrivacyPasskeys': undefined;
'SettingsItemPrivacyOn': undefined;
'SettingsItemPrivacyOff': undefined;
'SettingsPasskeyTitle': undefined;
'SettingsPasskeyInfo': undefined;
'SettingsPasskeyFallbackTitle': undefined;
'SettingsPasskeysFooterLink': undefined;
'SettingsPasskeysCreate': undefined;
'PasskeyModalTitle': undefined;
'PasskeyModalDescription': undefined;
'PasskeyModalFeature1Title': undefined;
'PasskeyModalFeature1Description': undefined;
'PasskeyModalFeature2Title': undefined;
'PasskeyModalFeature2Description': undefined;
'PasskeyModalFeature3Title': undefined;
'PasskeyModalFeature3Description': undefined;
'PasskeyModalButtonText': undefined;
'PasskeyDeleteTitle': undefined;
'PasskeyDeleteText': undefined;
'PasskeyCreateError': undefined;
'PasskeyLoginError': undefined;
'UnconfirmedAuthDeniedTitle': undefined; 'UnconfirmedAuthDeniedTitle': undefined;
'UnconfirmedAuthTitle': undefined; 'UnconfirmedAuthTitle': undefined;
'UnconfirmedAuthConfirm': undefined; 'UnconfirmedAuthConfirm': undefined;
@ -3098,6 +3119,12 @@ export interface LangPairWithVariables<V = LangVariable> {
'StarGiftPriceDecreaseTimer': { 'StarGiftPriceDecreaseTimer': {
'timer': V; 'timer': V;
}; };
'SettingsPasskeyUsedAt': {
'date': V;
};
'SettingsPasskeysFooter': {
'link': V;
};
'UnconfirmedAuthDeniedMessage': { 'UnconfirmedAuthDeniedMessage': {
'location': V; 'location': V;
}; };

View File

@ -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,
};
}

View File

@ -114,6 +114,8 @@ export const IS_TRANSLATION_SUPPORTED = !IS_TEST;
export const IS_TRANSLATION_DETECTOR_SUPPORTED = 'LanguageDetector' in window; export const IS_TRANSLATION_DETECTOR_SUPPORTED = 'LanguageDetector' in window;
export const IS_VIEW_TRANSITION_SUPPORTED = CSS.supports('view-transition-class: test') export const IS_VIEW_TRANSITION_SUPPORTED = CSS.supports('view-transition-class: test')
&& !IS_FIREFOX; // https://bugzilla.mozilla.org/show_bug.cgi?id=1994547 && !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; export const MESSAGE_LIST_SENSITIVE_AREA = 750;

View File

@ -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<ArrayBuffer> {
const base64 = base64UrlToBase64(base64Url);
return Buffer.from(base64, 'base64');
}
export function base64UrlToString(base64Url: string): string {
const buffer = base64UrlToBuffer(base64Url);
return buffer.toString('utf-8');
}

View File

@ -253,7 +253,6 @@ export default {
oneValue: '%1$d user', oneValue: '%1$d user',
otherValue: '%1$d users', otherValue: '%1$d users',
}, },
PasswordOn: 'On',
BlockedUsersInfo: 'Blocked users will not be able to contact you and will not see your Last Seen time.', 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.', 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', ChangePassword: 'Change Password',

View File

@ -19,7 +19,7 @@ export async function initGlobal(force: boolean = false, prevGlobal?: GlobalStat
const initial = cloneDeep(INITIAL_GLOBAL_STATE); const initial = cloneDeep(INITIAL_GLOBAL_STATE);
const cache = await loadCache(initial); const cache = await loadCache(initial);
let global = cache || 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; const { hasPasscode, isScreenLocked } = global.passcode;
if (hasPasscode && !isScreenLocked) { if (hasPasscode && !isScreenLocked) {

View File

@ -90,9 +90,9 @@ export function startWebsync() {
const timeout = WEBSYNC_TIMEOUT - (currentTs - ts); const timeout = WEBSYNC_TIMEOUT - (currentTs - ts);
lastTimeout = setTimeout(() => { lastTimeout = setTimeout(() => {
const { authState } = getGlobal(); const { auth } = getGlobal();
const authed = authState === 'authorizationStateReady' || hasStoredSession(); const authed = auth.state === 'authorizationStateReady' || hasStoredSession();
forceWebsync(authed); forceWebsync(authed);
}, Math.max(0, timeout * 1000)); }, Math.max(0, timeout * 1000));
} }

View File

@ -24,6 +24,8 @@ const {
HEAD, HEAD,
APP_ENV = 'production', APP_ENV = 'production',
APP_MOCKED_CLIENT = '', APP_MOCKED_CLIENT = '',
HTTPS_CERT_PATH = '',
HTTPS_KEY_PATH = '',
} = process.env; } = process.env;
const DEFAULT_APP_TITLE = `Telegram${APP_ENV !== 'production' ? ' Beta' : ''}`; const DEFAULT_APP_TITLE = `Telegram${APP_ENV !== 'production' ? ' Beta' : ''}`;
@ -57,6 +59,17 @@ export default function createConfig(
_: any, _: any,
{ mode = 'production' }: { mode: 'none' | 'development' | 'production' }, { mode = 'production' }: { mode: 'none' | 'development' | 'production' },
): Configuration { ): Configuration {
let server: Required<Configuration>['devServer']['server'] = 'http';
if (HTTPS_CERT_PATH && HTTPS_KEY_PATH) {
server = {
type: 'https',
options: {
key: HTTPS_KEY_PATH,
cert: HTTPS_CERT_PATH,
},
};
}
return { return {
mode, mode,
entry: './src/index.tsx', entry: './src/index.tsx',
@ -70,6 +83,7 @@ export default function createConfig(
client: { client: {
overlay: false, overlay: false,
}, },
server,
static: [ static: [
{ {
directory: path.resolve(__dirname, 'public'), directory: path.resolve(__dirname, 'public'),