Support Passkeys (#6535)
This commit is contained in:
parent
01d7cf294d
commit
41c2a17fdc
@ -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 {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
31
src/api/gramjs/gramjsBuilders/passkeys.ts
Normal file
31
src/api/gramjs/gramjsBuilders/passkeys.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@ -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'>;
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -5,6 +5,7 @@ export {
|
||||
|
||||
export {
|
||||
provideAuthPhoneNumber, provideAuthCode, provideAuthPassword, provideAuthRegistration, restartAuth, restartAuthWithQr,
|
||||
restartAuthWithPasskey,
|
||||
} from './auth';
|
||||
|
||||
export {
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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!";
|
||||
|
||||
@ -16,6 +16,7 @@ const INITIAL_KEYS: LangKey[] = [
|
||||
'LoginQRHelp2',
|
||||
'LoginQRHelp3',
|
||||
'LoginQRCancel',
|
||||
'LoginPasskey',
|
||||
'YourName',
|
||||
'LoginRegisterDesc',
|
||||
'LoginRegisterFirstNamePlaceholder',
|
||||
|
||||
@ -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 |
1
src/assets/tgs-previews/settings/Passkeys.svg
Normal file
1
src/assets/tgs-previews/settings/Passkeys.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 15 KiB |
BIN
src/assets/tgs/settings/Passkeys.tgs
Normal file
BIN
src/assets/tgs/settings/Passkeys.tgs
Normal file
Binary file not shown.
@ -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';
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -218,6 +218,7 @@ function LeftColumn({
|
||||
case SettingsScreens.PasscodeDisabled:
|
||||
case SettingsScreens.PasscodeEnabled:
|
||||
case SettingsScreens.PasscodeCongratulations:
|
||||
case SettingsScreens.Passkeys:
|
||||
openSettingsScreen({ screen: SettingsScreens.Privacy });
|
||||
return;
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -253,6 +253,10 @@ const SettingsHeader: FC<OwnProps> = ({
|
||||
)}
|
||||
</h3>
|
||||
);
|
||||
|
||||
case SettingsScreens.Passkeys:
|
||||
return <h3>{lang('SettingsPasskeyTitle')}</h3>;
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="settings-main-header">
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
.icon {
|
||||
--custom-emoji-size: 2rem;
|
||||
}
|
||||
|
||||
.fallbackIcon {
|
||||
padding-inline: 0.25rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
180
src/components/left/settings/SettingsPasskeys.tsx
Normal file
180
src/components/left/settings/SettingsPasskeys.tsx
Normal 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));
|
||||
@ -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));
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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;
|
||||
@ -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[];
|
||||
|
||||
14
src/components/modals/passkey/PasskeyModal.async.tsx
Normal file
14
src/components/modals/passkey/PasskeyModal.async.tsx
Normal 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;
|
||||
4
src/components/modals/passkey/PasskeyModal.module.scss
Normal file
4
src/components/modals/passkey/PasskeyModal.module.scss
Normal file
@ -0,0 +1,4 @@
|
||||
.title {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
78
src/components/modals/passkey/PasskeyModal.tsx
Normal file
78
src/components/modals/passkey/PasskeyModal.tsx
Normal 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);
|
||||
@ -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(),
|
||||
};
|
||||
},
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -113,7 +113,9 @@ export const INITIAL_GLOBAL_STATE: GlobalState = {
|
||||
lastPlaybackRate: DEFAULT_PLAYBACK_RATE,
|
||||
},
|
||||
|
||||
authRememberMe: true,
|
||||
auth: {
|
||||
rememberMe: true,
|
||||
},
|
||||
countryList: {
|
||||
phoneCodes: [],
|
||||
general: [],
|
||||
|
||||
15
src/global/reducers/auth.ts
Normal file
15
src/global/reducers/auth.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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?: {
|
||||
|
||||
@ -920,6 +920,8 @@ export type TabState = {
|
||||
threadId?: ThreadId;
|
||||
};
|
||||
|
||||
isPasskeyModalOpen?: boolean;
|
||||
|
||||
isWaitingForStarGiftUpgrade?: true;
|
||||
isWaitingForStarGiftTransfer?: true;
|
||||
insertingPeerIdMention?: string;
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<{
|
||||
|
||||
@ -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],
|
||||
]);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -158,4 +158,6 @@ export const DEFAULT_APP_CONFIG: ApiAppConfig = {
|
||||
'translations.telegram.org',
|
||||
],
|
||||
typingDraftTtl: 10,
|
||||
arePasskeysAvailable: true,
|
||||
passkeysMaxCount: 5,
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"]
|
||||
}
|
||||
|
||||
@ -262,6 +262,7 @@ export enum SettingsScreens {
|
||||
CustomEmoji,
|
||||
DoNotTranslate,
|
||||
FoldersShare,
|
||||
Passkeys,
|
||||
}
|
||||
|
||||
export type StickerSetOrReactionsSetOrRecent = Pick<ApiStickerSet, (
|
||||
|
||||
31
src/types/language.d.ts
vendored
31
src/types/language.d.ts
vendored
@ -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;
|
||||
};
|
||||
|
||||
17
src/util/browser/passkeys.ts
Normal file
17
src/util/browser/passkeys.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
16
src/util/encoding/base64.ts
Normal file
16
src/util/encoding/base64.ts
Normal 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');
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user