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;
message_typing_draft_ttl?: number;
contact_note_length_limit?: number;
settings_display_passkeys?: boolean;
passkeys_account_passkeys_max?: number;
}
function buildEmojiSounds(appConfig: GramJsAppConfig) {
@ -243,6 +245,8 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp
verifyAgeCountry: appConfig.verify_age_country,
verifyAgeMin: appConfig.verify_age_min,
typingDraftTtl: appConfig.message_typing_draft_ttl,
arePasskeysAvailable: appConfig.settings_display_passkeys,
passkeysMaxCount: appConfig.passkeys_account_passkeys_max,
};
return {

View File

@ -7,6 +7,7 @@ import type {
ApiCountry,
ApiLanguage,
ApiOldLangString,
ApiPasskey,
ApiPendingSuggestion,
ApiPrivacyKey,
ApiPromoData,
@ -349,3 +350,14 @@ export function buildApiRestrictionReasons(
{ reason, text, platform }) =>
({ reason, text, platform }));
}
export function buildApiPasskey(passkey: GramJs.TypePasskey): ApiPasskey {
const { id, name, date, softwareEmojiId, lastUsageDate } = passkey;
return {
id,
name,
date,
softwareEmojiId: softwareEmojiId?.toString(),
lastUsageDate,
};
}

View File

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

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',
CODE_INVALID: 'ErrorEmailCodeInvalid',
PASSWORD_MISSING: 'ErrorPasswordMissing',
PASSKEY_CREDENTIAL_NOT_FOUND: 'ErrorPasskeyUnknown',
};
export type MessageRepairContext = Pick<GramJs.TypeMessage, 'peerId' | 'id'>;

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import type {
ApiInputPrivacyRules,
ApiLanguage,
ApiNotifyPeerType,
ApiPasskeyRegistrationOption,
ApiPeerNotifySettings,
ApiPhoto,
ApiPrivacyKey,
@ -16,6 +17,7 @@ import type {
import {
ACCEPTABLE_USERNAME_ERRORS,
DEBUG,
LANG_PACK,
MUTE_INDEFINITE_TIMESTAMP,
UNMUTE_TIMESTAMP,
@ -28,6 +30,7 @@ import { buildApiDisallowedGiftsSettings } from '../apiBuilders/gifts';
import {
buildApiCountryList,
buildApiLanguage,
buildApiPasskey,
buildApiSession,
buildApiTimezone,
buildApiWallpaper,
@ -44,12 +47,14 @@ import {
import {
buildDisallowedGiftsSettings,
buildInputChannel,
buildInputPeer, buildInputPhoto,
buildInputPeer,
buildInputPhoto,
buildInputPrivacyKey,
buildInputPrivacyRules,
buildInputUser,
DEFAULT_PRIMITIVES,
} from '../gramjsBuilders';
import { buildInputPasskeyCredential } from '../gramjsBuilders/passkeys';
import { addPhotoToLocalDb } from '../helpers/localDb';
import localDb from '../localDb';
import { getClient, invokeRequest, uploadFile } from './client';
@ -766,3 +771,48 @@ export function reorderUsernames({ chatId, accessHash, usernames }: {
order: usernames,
}));
}
export async function fetchPasskeys() {
const result = await invokeRequest(new GramJs.account.GetPasskeys());
if (!result) {
return undefined;
}
return {
passkeys: result.passkeys.map(buildApiPasskey),
};
}
export async function initPasskeyRegistration() {
const result = await invokeRequest(new GramJs.account.InitPasskeyRegistration());
if (!result) {
return undefined;
}
try {
return JSON.parse(result.options.data) as ApiPasskeyRegistrationOption;
} catch (err: unknown) {
if (DEBUG) {
// eslint-disable-next-line no-console
console.warn('Failed to parse passkey registration options:', err);
}
}
return undefined;
}
export async function registerPasskey(credentialJson: PublicKeyCredentialJSON) {
const result = await invokeRequest(new GramJs.account.RegisterPasskey({
credential: buildInputPasskeyCredential(credentialJson),
}));
if (!result) {
return undefined;
}
return buildApiPasskey(result);
}
export function deletePasskey({ id }: { id: string }) {
return invokeRequest(new GramJs.account.DeletePasskey({ id }), {
shouldReturnTrue: true,
});
}

View File

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

View File

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

View File

@ -175,6 +175,7 @@
"LoginQRHelp2" = "Go to **Settings** > **Devices** > **Link Desktop Device**";
"LoginQRHelp3" = "Point your phone at this screen to confirm login";
"LoginQRCancel" = "Log in by phone number";
"LoginPasskey" = "Log in with Passkey";
"YourName" = "Your Name";
"LoginRegisterDesc" = "Enter your name and add a profile photo.";
"LoginRegisterFirstNamePlaceholder" = "First Name";
@ -519,8 +520,6 @@
"Users_one" = "{count} user";
"Users_other" = "{count} users";
"PrivacySettingsWebSessions" = "Active Websites";
"PasswordOn" = "On";
"PasswordOff" = "Off";
"PrivacyTitle" = "Privacy";
"PrivacyPhoneTitle" = "Who can see my phone number?";
"LastSeenTitle" = "Who can see your Last Seen time?";
@ -717,6 +716,7 @@
"ErrorNewSaltInvalid" = "Error validating password, please try again";
"ErrorPasswordChanged" = "Password has been changed, please try again";
"ErrorPasswordMissing" = "You must set 2FA password to use this feature";
"ErrorPasskeyUnknown" = "This passkey is not assigned to any account";
"ErrorUnspecified" = "Error";
"NoStickers" = "No stickers yet";
"ClearRecentEmoji" = "Clear recent emoji?";
@ -2406,6 +2406,29 @@
"StarGiftUpgradeCostModalTitle" = "Upgrade Cost";
"StarGiftUpgradeCostHint" = "Users who upgrade their gifts first get collectibles with shorter numbers.";
"StarGiftPriceDecreaseTimer" = "Price decreases in {timer}";
"SettingsItemPrivacyPasskeys" = "Passkeys";
"SettingsItemPrivacyOn" = "Enabled";
"SettingsItemPrivacyOff" = "Disabled";
"SettingsPasskeyUsedAt" = "Last used {date}";
"SettingsPasskeyTitle" = "Passkeys";
"SettingsPasskeyInfo" = "Manage your passkey, stored safely in the cloud service you choose.";
"SettingsPasskeyFallbackTitle" = "Passkey";
"SettingsPasskeysFooter" = "Your passkeys are stored securely in your password manager. {link}";
"SettingsPasskeysFooterLink" = "Learn more >";
"SettingsPasskeysCreate" = "Create a Passkey";
"PasskeyModalTitle" = "Protect your account";
"PasskeyModalDescription" = "Log in safely and keep your account secure.";
"PasskeyModalFeature1Title" = "Create a Passkey";
"PasskeyModalFeature1Description" = "Make a passkey to sign in easily and safely.";
"PasskeyModalFeature2Title" = "Log in with face recognition";
"PasskeyModalFeature2Description" = "Use your face, fingerprint, or passcode to sign in.";
"PasskeyModalFeature3Title" = "Store Passkey securely";
"PasskeyModalFeature3Description" = "Your passkey is stored safely in the cloud service you choose.";
"PasskeyModalButtonText" = "Create Passkey";
"PasskeyDeleteTitle" = "Delete Passkey";
"PasskeyDeleteText" = "Once deleted, this passkey can't be used to log in.\nDon't forget to remove it from your password manager.";
"PasskeyCreateError" = "Failed to create passkey. Please try again.";
"PasskeyLoginError" = "Failed to log in with a passkey";
"UnconfirmedAuthDeniedTitle" = "New Login Prevented";
"UnconfirmedAuthDeniedMessage" = "We have terminated the login attempt from {location}.";
"UnconfirmedAuthTitle" = "Someone just got access to your messages!";

View File

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

View File

@ -18,7 +18,8 @@ export default {
"LoginQRHelp1": "Open Telegram on your phone",
"LoginQRHelp2": "Go to **Settings** > **Devices** > **Link Desktop Device**",
"LoginQRHelp3": "Point your phone at this screen to confirm login",
"LoginQRCancel": "Log in by phone Number",
"LoginQRCancel": "Log in by phone number",
"LoginPasskey": "Log in with Passkey",
"YourName": "Your Name",
"LoginRegisterDesc": "Enter your name and add a profile photo.",
"LoginRegisterFirstNamePlaceholder": "First Name",
@ -26,12 +27,12 @@ export default {
"LoginSelectCountryTitle": "Country",
"CountryNone": "Country not found",
"PleaseEnterPassword": "Enter your new password",
"ErrorPhoneNumberInvalid": "Invalid phone number, please try again.",
"ErrorCodeInvalid": "Invalid code, please try again.",
"ErrorIncorrectPassword": "Invalid password, please try again.",
"ErrorPasswordFlood": "Too many attempts, please try again later.",
"ErrorPhoneBanned": "This phone number is banned.",
"ErrorFloodTime": "Too many attempts, please try again in {time}.",
"ErrorPhoneNumberInvalid": "Invalid phone number, please try again",
"ErrorCodeInvalid": "Invalid code, please try again",
"ErrorIncorrectPassword": "Invalid password, please try again",
"ErrorPasswordFlood": "Too many attempts, please try again later",
"ErrorPhoneBanned": "This phone number is banned",
"ErrorFloodTime": "Too many attempts, please try again in {time}",
"ErrorUnexpected": "Unexpected error",
"ErrorUnexpectedMessage": "Unexpected error: {error}"
} as Record<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 AttachBotRecipientPicker } from '../components/main/AttachBotRecipientPicker';
export { default as Dialogs } from '../components/main/Dialogs';
export { default as Notifications } from '../components/main/Notifications';
export { default as SafeLinkModal } from '../components/main/SafeLinkModal';
export { default as MapModal } from '../components/modals/map/MapModal';
export { default as UrlAuthModal } from '../components/modals/urlAuth/UrlAuthModal';
@ -27,6 +26,7 @@ export { default as DeleteAccountModal } from '../components/modals/deleteAccoun
export { default as AgeVerificationModal } from '../components/modals/ageVerification/AgeVerificationModal';
export { default as ChatlistModal } from '../components/modals/chatlist/ChatlistModal';
export { default as ChatInviteModal } from '../components/modals/chatInvite/ChatInviteModal';
export { default as PasskeyModal } from '../components/modals/passkey/PasskeyModal';
export { default as BirthdaySetupModal } from '../components/modals/birthday/BirthdaySetupModal';
export { default as AboutAdsModal } from '../components/modals/aboutAds/AboutAdsModal';

View File

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

View File

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

View File

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

View File

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

View File

@ -28,11 +28,11 @@ import Loading from '../ui/Loading';
import blankUrl from '../../assets/blank.png';
type StateProps =
Pick<GlobalState, 'connectionState' | 'authState' | 'authQrCode'>
& {
language?: string;
};
type StateProps = {
auth: GlobalState['auth'];
connectionState: GlobalState['connectionState'];
language?: string;
};
const DATA_PREFIX = 'tg://login?token=';
const QR_SIZE = 280;
@ -50,15 +50,17 @@ function ensureQrCodeStyling() {
const AuthCode = ({
connectionState,
authState,
authQrCode,
auth,
language,
}: StateProps) => {
const {
returnToAuthPhoneNumber,
setSharedSettingOption,
loginWithPasskey,
} = getActions();
const { state, qrCode: authQrCode, passkeyOption } = auth;
const suggestedLanguage = getSuggestedLanguage();
const lang = useLang();
const qrCodeRef = useRef<HTMLDivElement>();
@ -151,7 +153,11 @@ const AuthCode = ({
returnToAuthPhoneNumber();
});
const isAuthReady = authState === 'authorizationStateWaitQrCode';
const handleLoginWithPasskey = useLastCallback(() => {
loginWithPasskey();
});
const isAuthReady = state === 'authorizationStateWaitQrCode';
return (
<div id="auth-qr-form" className="custom-scroll">
@ -198,6 +204,11 @@ const AuthCode = ({
{lang('LoginQRCancel')}
</Button>
)}
{passkeyOption && (
<Button className="auth-button" isText onClick={handleLoginWithPasskey}>
{lang('LoginPasskey')}
</Button>
)}
{suggestedLanguage && suggestedLanguage !== language && continueText && (
<Button className="auth-button" isText isLoading={isLoading} onClick={handleLangChange}>
{continueText}
@ -211,15 +222,14 @@ const AuthCode = ({
export default memo(withGlobal(
(global): Complete<StateProps> => {
const {
connectionState, authState, authQrCode,
connectionState, auth,
} = global;
const { language } = selectSharedSettings(global);
return {
connectionState,
authState,
authQrCode,
auth,
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 { getActions, withGlobal } from '../../global';
@ -14,12 +11,15 @@ import AvatarEditable from '../ui/AvatarEditable';
import Button from '../ui/Button';
import InputText from '../ui/InputText';
type StateProps = Pick<GlobalState, 'authIsLoading' | 'authErrorKey'>;
type StateProps = {
auth: GlobalState['auth'];
};
const AuthRegister: FC<StateProps> = ({
authIsLoading, authErrorKey,
}) => {
const AuthRegister = ({
auth,
}: StateProps) => {
const { signUp, clearAuthErrorKey, uploadProfilePhoto } = getActions();
const { isLoading, errorKey } = auth;
const lang = useLang();
const [isButtonShown, setIsButtonShown] = useState(false);
@ -27,8 +27,8 @@ const AuthRegister: FC<StateProps> = ({
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const handleFirstNameChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
if (authErrorKey) {
const handleFirstNameChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
if (errorKey) {
clearAuthErrorKey();
}
@ -36,9 +36,9 @@ const AuthRegister: FC<StateProps> = ({
setFirstName(target.value);
setIsButtonShown(target.value.length > 0);
}, [authErrorKey]);
}, [errorKey]);
const handleLastNameChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
const handleLastNameChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const { target } = event;
setLastName(target.value);
@ -66,7 +66,7 @@ const AuthRegister: FC<StateProps> = ({
label={lang('LoginRegisterFirstNamePlaceholder')}
onChange={handleFirstNameChange}
value={firstName}
error={authErrorKey && lang.withRegular(authErrorKey)}
error={errorKey && lang.withRegular(errorKey)}
autoComplete="given-name"
/>
<InputText
@ -77,7 +77,7 @@ const AuthRegister: FC<StateProps> = ({
autoComplete="family-name"
/>
{isButtonShown && (
<Button type="submit" ripple isLoading={authIsLoading}>{lang('Next')}</Button>
<Button type="submit" ripple isLoading={isLoading}>{lang('Next')}</Button>
)}
</form>
</div>
@ -87,6 +87,6 @@ const AuthRegister: FC<StateProps> = ({
export default memo(withGlobal(
(global): Complete<StateProps> => (
pick(global, ['authIsLoading', 'authErrorKey']) as Complete<StateProps>
pick(global, ['auth'])
),
)(AuthRegister));

View File

@ -1,4 +1,3 @@
import type { FC } from '../../lib/teact/teact';
import { memo } from '../../lib/teact/teact';
import { withGlobal } from '../../global';
@ -13,7 +12,7 @@ type StateProps = {
notifications: ApiNotification[];
};
const Notifications: FC<StateProps> = ({ notifications }) => {
const Notifications = ({ notifications }: StateProps) => {
if (!notifications.length) {
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 FoldersShare from '../../../assets/tgs/settings/FoldersShare.tgs';
import Lock from '../../../assets/tgs/settings/Lock.tgs';
import Passkeys from '../../../assets/tgs/settings/Passkeys.tgs';
import StarReaction from '../../../assets/tgs/stars/StarReaction.tgs';
import StarReactionEffect from '../../../assets/tgs/stars/StarReactionEffect.tgs';
import Unlock from '../../../assets/tgs/Unlock.tgs';
import DuckNothingFoundPreview from '../../../assets/tgs-previews/DuckNothingFound.svg';
import SearchPreview from '../../../assets/tgs-previews/Search.svg';
import PasskeysPreview from '../../../assets/tgs-previews/settings/Passkeys.svg';
export const LOCAL_TGS_PREVIEW_URLS = {
DuckNothingFound: DuckNothingFoundPreview,
Search: SearchPreview,
Passkeys: PasskeysPreview,
};
export const LOCAL_TGS_URLS = {
@ -82,5 +85,6 @@ export const LOCAL_TGS_URLS = {
Diamond,
Search,
DuckNothingFound,
Passkeys,
DuckCake,
};

View File

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

View File

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

View File

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

View File

@ -85,7 +85,6 @@ import ForwardRecipientPicker from './ForwardRecipientPicker.async';
import GameModal from './GameModal';
import HistoryCalendar from './HistoryCalendar.async';
import NewContactModal from './NewContactModal.async';
import Notifications from './Notifications.async';
import PremiumLimitReachedModal from './premium/common/PremiumLimitReachedModal.async';
import GiveawayModal from './premium/GiveawayModal.async';
import PremiumMainModal from './premium/PremiumMainModal.async';
@ -110,7 +109,6 @@ type StateProps = {
isMediaViewerOpen: boolean;
isStoryViewerOpen: boolean;
isForwardModalOpen: boolean;
hasNotifications: boolean;
hasDialogs: boolean;
safeLinkModalUrl?: string;
isHistoryCalendarOpen: boolean;
@ -163,7 +161,6 @@ const Main = ({
isMediaViewerOpen,
isStoryViewerOpen,
isForwardModalOpen,
hasNotifications,
hasDialogs,
activeGroupCallId,
safeLinkModalUrl,
@ -564,7 +561,6 @@ const Main = ({
<StoryViewer isOpen={isStoryViewerOpen} />
<ForwardRecipientPicker isOpen={isForwardModalOpen} />
<DraftRecipientPicker requestedDraft={requestedDraft} />
<Notifications isOpen={hasNotifications} />
<Dialogs isOpen={hasDialogs} />
<AudioPlayer noUi />
<ModalContainer />
@ -631,7 +627,6 @@ export default memo(withGlobal<OwnProps>(
openedGame,
isLeftColumnShown,
historyCalendarSelectedAt,
notifications,
dialogs,
newContact,
ratingPhoneCall,
@ -665,7 +660,6 @@ export default memo(withGlobal<OwnProps>(
isStoryViewerOpen: selectIsStoryViewerOpen(global),
isForwardModalOpen: selectIsForwardModalOpen(global),
isReactionPickerOpen: selectIsReactionPickerOpen(global),
hasNotifications: Boolean(notifications.length),
hasDialogs: Boolean(dialogs.length),
safeLinkModalUrl,
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 { memo } from '../../lib/teact/teact';
import { type FC, memo } from '../../lib/teact/teact';
import { withGlobal } from '../../global';
import type { TabState } from '../../global/types';
@ -39,6 +38,7 @@ import LocationAccessModal from './locationAccess/LocationAccessModal.async';
import MapModal from './map/MapModal.async';
import OneTimeMediaModal from './oneTimeMedia/OneTimeMediaModal.async';
import PaidReactionModal from './paidReaction/PaidReactionModal.async';
import PasskeyModal from './passkey/PasskeyModal.async';
import PreparedMessageModal from './preparedMessage/PreparedMessageModal.async';
import PriceConfirmModal from './priceConfirm/PriceConfirmModal.async';
import ProfileRatingModal from './profileRating/ProfileRatingModal.async';
@ -111,6 +111,7 @@ type ModalKey = keyof Pick<TabState,
'profileRatingModal' |
'quickPreview' |
'storyStealthModal' |
'isPasskeyModalOpen' |
'birthdaySetupModal'
>;
@ -177,6 +178,7 @@ const MODALS: ModalRegistry = {
profileRatingModal: ProfileRatingModal,
quickPreview: QuickPreviewModal,
storyStealthModal: StealthModeModal,
isPasskeyModalOpen: PasskeyModal,
birthdaySetupModal: BirthdaySetupModal,
};
const MODAL_KEYS = Object.keys(MODALS) as ModalKey[];

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,6 +2,7 @@ import type { ApiPrivacySettings, ApiUsername } from '../../../api/types';
import type { ActionReturnType } from '../../types';
import {
ProfileEditProgress,
SettingsScreens,
UPLOADING_WALLPAPER_SLUG,
} from '../../../types';
@ -11,6 +12,7 @@ import {
MUTE_INDEFINITE_TIMESTAMP,
UNMUTE_TIMESTAMP,
} from '../../../config';
import { toCredentialCreationOptions } from '../../../util/browser/passkeys';
import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { buildCollectionByKey } from '../../../util/iteratees';
import { requestPermission, subscribe, unsubscribe } from '../../../util/notifications';
@ -19,7 +21,7 @@ import requestActionTimeout from '../../../util/requestActionTimeout';
import { getServerTime } from '../../../util/serverTime';
import { callApi } from '../../../api/gramjs';
import { buildApiInputPrivacyRules } from '../../helpers';
import { addActionHandler, getGlobal, setGlobal } from '../../index';
import { addActionHandler, getGlobal, getPromiseActions, setGlobal } from '../../index';
import {
addBlockedUser, addNotifyExceptions, deletePeerPhoto,
removeBlockedUser, replaceSettings, updateChat,
@ -636,9 +638,9 @@ addActionHandler('loadCountryList', async (global, actions, payload): Promise<vo
});
addActionHandler('ensureTimeFormat', async (global, actions): Promise<void> => {
if (global.authNearestCountry) {
if (global.auth.nearestCountry) {
const timeFormat = COUNTRIES_WITH_12H_TIME_FORMAT
.has(global.authNearestCountry.toUpperCase()) ? '12h' : '24h';
.has(global.auth.nearestCountry.toUpperCase()) ? '12h' : '24h';
actions.setSharedSettingOption({ timeFormat });
setTimeFormat(timeFormat);
}
@ -947,3 +949,79 @@ addActionHandler('sortChatUsernames', async (global, actions, payload): Promise<
setGlobal(global);
}
});
addActionHandler('loadPasskeys', async (global): Promise<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;
// RAF can be unreliable when device goes into sleep mode, so sync logic is handled outside any component
addCallback((global: GlobalState) => {
const { connectionState, authState, isSynced } = global;
const { connectionState, auth, isSynced } = global;
const { isMasterTab } = selectTabState(global);
if (!isMasterTab || isSynced || (previousGlobal?.connectionState === connectionState
&& previousGlobal?.authState === authState)) {
&& previousGlobal?.auth.state === auth.state)) {
previousGlobal = global;
return;
}
if (connectionState === 'connectionStateReady' && authState === 'authorizationStateReady') {
if (connectionState === 'connectionStateReady' && auth.state === 'authorizationStateReady') {
getActions().sync();
}

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import { addCallback } from '../../../lib/teact/teactn';
import type { ApiError, ApiNotification } from '../../../api/types';
import type { ApiError } from '../../../api/types';
import type { ActionReturnType, GlobalState } from '../../types';
import {
@ -12,7 +12,6 @@ import { IS_TAURI } from '../../../util/browser/globalEnvironment';
import { IS_WAVE_TRANSFORM_SUPPORTED } from '../../../util/browser/windowEnvironment';
import { getAllMultitabTokens, getCurrentTabId, reestablishMasterToSelf } from '../../../util/establishMultitabRole';
import { getAllNotificationsCount } from '../../../util/folderManager';
import generateUniqueId from '../../../util/generateUniqueId';
import getIsAppUpdateNeeded from '../../../util/getIsAppUpdateNeeded';
import getReadableErrorText from '../../../util/getReadableErrorText';
import { compact, unique } from '../../../util/iteratees';
@ -332,26 +331,6 @@ addActionHandler('reorderStickerSets', (global, actions, payload): ActionReturnT
};
});
addActionHandler('showNotification', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId(), ...notification } = payload;
const hasLocalId = notification.localId;
notification.localId ||= generateUniqueId();
const newNotifications = [...selectTabState(global, tabId).notifications];
const existingNotificationIndex = newNotifications.findIndex((n) => (
hasLocalId ? n.localId === notification.localId : n.message === notification.message
));
if (existingNotificationIndex !== -1) {
newNotifications.splice(existingNotificationIndex, 1);
}
newNotifications.push(notification as ApiNotification);
return updateTabState(global, {
notifications: newNotifications,
}, tabId);
});
addActionHandler('showAllowedMessageTypesNotification', (global, actions, payload): ActionReturnType => {
const { chatId, messageListType, tabId = getCurrentTabId() } = payload;
@ -405,16 +384,6 @@ addActionHandler('showAllowedMessageTypesNotification', (global, actions, payloa
});
});
addActionHandler('dismissNotification', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload;
const newNotifications = selectTabState(global, tabId)
.notifications.filter(({ localId }) => localId !== payload.localId);
return updateTabState(global, {
notifications: newNotifications,
}, tabId);
});
addActionHandler('showDialog', (global, actions, payload): ActionReturnType => {
const { data, tabId = getCurrentTabId() } = payload;

View File

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

View File

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

View File

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

View File

@ -113,7 +113,9 @@ export const INITIAL_GLOBAL_STATE: GlobalState = {
lastPlaybackRate: DEFAULT_PLAYBACK_RATE,
},
authRememberMe: true,
auth: {
rememberMe: true,
},
countryList: {
phoneCodes: [],
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) {
// TODO[passcode]: remove this when multiacc passcode is implemented
const accounts = getAccountsInfo();
return global.authRememberMe && !ACCOUNT_SLOT && Object.keys(accounts).length === 1;
return global.auth.rememberMe && !ACCOUNT_SLOT && Object.keys(accounts).length === 1;
}
export function selectTranslationLanguage<T extends GlobalState>(global: T) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -130,7 +130,7 @@ export async function generateKeyDataFromNonce(
}
export function convertToLittle(buf: Uint32Array) {
const correct = Buffer.alloc(buf.length * 4);
const correct = Buffer.allocUnsafe(buf.length * 4);
for (let i = 0; i < buf.length; i++) {
correct.writeUInt32BE(buf[i], i * 4);

View File

@ -1,10 +1,16 @@
import type { ApiPasskeyOption } from '../../../api/types';
import type TelegramClient from './TelegramClient';
import type { Update } from './TelegramClient';
import { DEBUG } from '../../../config';
import { base64UrlToString } from '../../../util/encoding/base64';
import { tryParseBigInt } from '../../../util/numbers';
import { getServerTime } from '../../../util/serverTime';
import { DEFAULT_PRIMITIVES } from '../../../api/gramjs/gramjsBuilders';
import { RPCError } from '../errors';
import { buildInputPasskeyCredential } from '../../../api/gramjs/gramjsBuilders/passkeys';
import {
PasskeyCredentialNotFoundError, PasskeyLoginRequestedError, RPCError, UserAlreadyAuthorizedError,
} from '../errors';
import Api from '../tl/api';
import { sleep } from '../Helpers';
@ -14,6 +20,7 @@ import { getDisplayName } from '../Utils';
export interface UserAuthParams {
phoneNumber: string | (() => Promise<string>);
webAuthTokenFailed: () => void;
onPasskeyOption: (passkeyOption: ApiPasskeyOption) => void;
phoneCode: (isCodeViaApp?: boolean) => Promise<string>;
password: (hint?: string, noReset?: boolean) => Promise<string>;
firstAndLastNames: () => Promise<[string, string?]>;
@ -24,6 +31,7 @@ export interface UserAuthParams {
shouldThrowIfUnauthorized?: boolean;
webAuthToken?: string;
accountIds?: string[];
hasPasskeySupport?: boolean;
mockScenario?: string;
}
@ -36,7 +44,9 @@ interface ApiCredentials {
apiHash: string;
}
const DEFAULT_INITIAL_METHOD = 'phoneNumber';
type AuthMethod = 'phoneNumber' | 'qrCode';
const DEFAULT_INITIAL_METHOD: AuthMethod = 'phoneNumber';
let lastUsedMethod: AuthMethod = DEFAULT_INITIAL_METHOD;
export async function authFlow(
client: TelegramClient,
@ -61,6 +71,8 @@ export function signInUserWithPreferredMethod(
): Promise<Api.TypeUser> {
const { initialMethod = DEFAULT_INITIAL_METHOD } = authParams;
refreshPasskeyLoginOption(client, apiCredentials, authParams);
if (initialMethod === 'phoneNumber') {
return signInUser(client, apiCredentials, authParams);
} else {
@ -68,6 +80,22 @@ export function signInUserWithPreferredMethod(
}
}
function refreshPasskeyLoginOption(
client: TelegramClient, apiCredentials: ApiCredentials, authParams: UserAuthParams,
) {
if (!authParams.hasPasskeySupport) return;
obtainPasskeyLoginOption(client, apiCredentials).then((passkeyOption) => {
if (passkeyOption) {
authParams.onPasskeyOption(passkeyOption);
}
}).catch((err: unknown) => {
if (DEBUG) {
// eslint-disable-next-line no-console
console.error('Failed to obtain passkey login option:', err);
}
});
}
export async function checkAuthorization(client: TelegramClient, shouldThrow = false) {
try {
await client.invoke(new Api.updates.GetState());
@ -114,6 +142,7 @@ async function signInUser(
let phoneNumber;
let phoneCodeHash;
let isCodeViaApp = false;
lastUsedMethod = 'phoneNumber';
while (true) {
try {
@ -122,7 +151,11 @@ async function signInUser(
phoneNumber = await authParams.phoneNumber();
} catch (err: unknown) {
if (err instanceof Error && err.message === 'RESTART_AUTH_WITH_QR') {
return signInUserWithQrCode(client, apiCredentials, authParams);
return await signInUserWithQrCode(client, apiCredentials, authParams);
}
if (err instanceof PasskeyLoginRequestedError) {
return await signInUserWithPasskey(client, apiCredentials, authParams, err.credentialJson);
}
throw err;
@ -235,6 +268,8 @@ async function signInUserWithQrCode(
const { apiId, apiHash } = apiCredentials;
const exceptIds = authParams.accountIds?.map((id) => tryParseBigInt(id)).filter(Boolean) || [];
lastUsedMethod = 'qrCode';
const inputPromise = (async () => {
// eslint-disable-next-line no-constant-condition
while (1) {
@ -277,6 +312,10 @@ async function signInUserWithQrCode(
return await signInUser(client, apiCredentials, authParams);
}
if (err instanceof PasskeyLoginRequestedError) {
return await signInUserWithPasskey(client, apiCredentials, authParams, err.credentialJson);
}
throw err;
} finally {
isScanningComplete = true;
@ -315,6 +354,102 @@ async function signInUserWithQrCode(
throw undefined;
}
export async function obtainPasskeyLoginOption(
client: TelegramClient, apiCredentials: ApiCredentials,
): Promise<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(
client: TelegramClient, apiCredentials: ApiCredentials, phoneNumber: string, forceSMS = false,
): 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>([
[/FILE_MIGRATE_(\d+)/, FileMigrateError],
[/FLOOD_TEST_PHONE_WAIT_(\d+)/, FloodTestPhoneWaitError],
@ -161,4 +186,5 @@ export const rpcErrorRe = new Map<RegExp, any>([
[/EMAIL_UNCONFIRMED_(\d+)/, EmailUnconfirmedError],
[/PASSWORD_TOO_FRESH_(\d+)/, PasswordFreshError],
[/^Timeout$/, TimedOutError],
[/PASSKEY_CREDENTIAL_NOT_FOUND/, PasskeyCredentialNotFoundError],
]);

View File

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

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.importLoginToken#95ac5ce4 token:bytes = auth.LoginToken;
auth.importWebTokenAuthorization#2db873a9 api_id:int api_hash:string web_auth_token:string = auth.Authorization;
auth.initPasskeyLogin#518ad0b7 api_id:int api_hash:string = auth.PasskeyLoginOptions;
auth.finishPasskeyLogin#9857ad07 flags:# credential:InputPasskeyCredential from_dc_id:flags.0?int from_auth_key_id:flags.0?long = auth.Authorization;
account.registerDevice#ec86017a flags:# no_muted:flags.0?true token_type:int token:string app_sandbox:Bool secret:bytes other_uids:Vector<long> = Bool;
account.unregisterDevice#6a0d3206 token_type:int token:string other_uids:Vector<long> = Bool;
account.updateNotifySettings#84be5b93 peer:InputNotifyPeer settings:InputPeerNotifySettings = Bool;
@ -1593,6 +1595,10 @@ account.getCollectibleEmojiStatuses#2e7b4543 hash:long = account.EmojiStatuses;
account.getPaidMessagesRevenue#19ba4a67 flags:# parent_peer:flags.0?InputPeer user_id:InputUser = account.PaidMessagesRevenue;
account.toggleNoPaidMessagesException#fe2eda76 flags:# refund_charged:flags.0?true require_payment:flags.2?true parent_peer:flags.1?InputPeer user_id:InputUser = Bool;
account.setMainProfileTab#5dee78b0 tab:ProfileTab = Bool;
account.initPasskeyRegistration#429547e8 = account.PasskeyRegistrationOptions;
account.registerPasskey#55b41fd6 credential:InputPasskeyCredential = Passkey;
account.getPasskeys#ea1f0c52 = account.Passkeys;
account.deletePasskey#f5b5563f id:string = Bool;
users.getUsers#d91a548 id:Vector<InputUser> = Vector<User>;
users.getFullUser#b60f5918 id:InputUser = users.UserFull;
contacts.getContacts#5dd69e12 hash:long = contacts.Contacts;

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -157,6 +157,7 @@ export interface LangPair {
'LoginQRHelp2': undefined;
'LoginQRHelp3': undefined;
'LoginQRCancel': undefined;
'LoginPasskey': undefined;
'YourName': undefined;
'LoginRegisterDesc': undefined;
'LoginRegisterFirstNamePlaceholder': undefined;
@ -468,8 +469,6 @@ export interface LangPair {
'P2PContacts': undefined;
'P2PNobody': undefined;
'PrivacySettingsWebSessions': undefined;
'PasswordOn': undefined;
'PasswordOff': undefined;
'PrivacyTitle': undefined;
'PrivacyPhoneTitle': undefined;
'LastSeenTitle': undefined;
@ -634,6 +633,7 @@ export interface LangPair {
'ErrorNewSaltInvalid': undefined;
'ErrorPasswordChanged': undefined;
'ErrorPasswordMissing': undefined;
'ErrorPasskeyUnknown': undefined;
'ErrorUnspecified': undefined;
'NoStickers': undefined;
'ClearRecentEmoji': undefined;
@ -1788,6 +1788,27 @@ export interface LangPair {
'StarGiftPriceDecreaseInfoLink': undefined;
'StarGiftUpgradeCostModalTitle': 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;
'UnconfirmedAuthTitle': undefined;
'UnconfirmedAuthConfirm': undefined;
@ -3098,6 +3119,12 @@ export interface LangPairWithVariables<V = LangVariable> {
'StarGiftPriceDecreaseTimer': {
'timer': V;
};
'SettingsPasskeyUsedAt': {
'date': V;
};
'SettingsPasskeysFooter': {
'link': V;
};
'UnconfirmedAuthDeniedMessage': {
'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_VIEW_TRANSITION_SUPPORTED = CSS.supports('view-transition-class: test')
&& !IS_FIREFOX; // https://bugzilla.mozilla.org/show_bug.cgi?id=1994547
export const IS_WEBAUTHN_SUPPORTED = navigator.credentials && window.PublicKeyCredential
&& 'parseCreationOptionsFromJSON' in PublicKeyCredential;
export const MESSAGE_LIST_SENSITIVE_AREA = 750;

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',
otherValue: '%1$d users',
},
PasswordOn: 'On',
BlockedUsersInfo: 'Blocked users will not be able to contact you and will not see your Last Seen time.',
EnabledPasswordText: 'You have enabled Two-Step verification.\nYou\'ll need the password you set up here to log in to your Telegram account.',
ChangePassword: 'Change Password',

View File

@ -19,7 +19,7 @@ export async function initGlobal(force: boolean = false, prevGlobal?: GlobalStat
const initial = cloneDeep(INITIAL_GLOBAL_STATE);
const cache = await loadCache(initial);
let global = cache || initial;
if (IS_MOCKED_CLIENT) global.authState = 'authorizationStateReady';
if (IS_MOCKED_CLIENT) global.auth.state = 'authorizationStateReady';
const { hasPasscode, isScreenLocked } = global.passcode;
if (hasPasscode && !isScreenLocked) {

View File

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

View File

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