mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
fix(app): smooth the fluxer.com migration and expired re-login (#3019)
This commit is contained in:
1 parent
a728be4062
commit
c9ae5b6ee8
75 files changed
+3284
-2368
No files matched your search
@@ -23,6 +23,7 @@
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
text-align: start;
|
||||
background-color: var(--background-secondary);
|
||||
border: 0.0625rem solid var(--background-modifier-accent);
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export const AccountRow = observer(
|
||||
const variantClassName = variant === 'manage' ? styles.manage : variant === 'compact' ? styles.compact : undefined;
|
||||
const isClickable = typeof onClick === 'function';
|
||||
const MainButtonComponent = isClickable ? 'button' : 'div';
|
||||
const showMenuButton = Boolean(onMenuClick && variant !== 'compact' && !showCaretIndicator);
|
||||
const showMenuButton = Boolean(onMenuClick && variant !== 'compact');
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.row, variantClassName, showMenuButton && styles.withMenu, className)}
|
||||
|
||||
@@ -32,10 +32,6 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.accountListWrapper {
|
||||
min-height: 7.5rem;
|
||||
}
|
||||
|
||||
.scroller {
|
||||
max-height: 17.5rem;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import {Scroller} from '@app/features/ui/components/Scroller';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {PlusIcon, SignOutIcon} from '@phosphor-icons/react';
|
||||
import {PlusIcon, SignInIcon, SignOutIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useCallback} from 'react';
|
||||
@@ -101,17 +101,20 @@ export const AccountSelector = observer(
|
||||
(account: Account) => (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
ContextMenuCommands.openFromEvent(event, (props) => (
|
||||
<MenuGroup data-flx="auth.accounts.account-selector.open-menu.menu-group">
|
||||
<MenuItem
|
||||
icon={<SignOutIcon size={18} data-flx="auth.accounts.account-selector.open-menu.sign-out-icon" />}
|
||||
icon={<SignInIcon size={18} data-flx="auth.accounts.account-selector.open-menu.sign-in-icon" />}
|
||||
onClick={() => {
|
||||
props.onClose();
|
||||
onSelectAccount(account);
|
||||
}}
|
||||
data-flx="auth.accounts.account-selector.open-menu.menu-item.close"
|
||||
>
|
||||
<Trans>Select account</Trans>
|
||||
{account.isValid === false ? <Trans>Sign in again</Trans> : <Trans>Select account</Trans>}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
danger
|
||||
@@ -127,7 +130,7 @@ export const AccountSelector = observer(
|
||||
</MenuGroup>
|
||||
));
|
||||
},
|
||||
[openSignOutConfirm, onSelectAccount],
|
||||
[disabled, openSignOutConfirm, onSelectAccount],
|
||||
);
|
||||
return (
|
||||
<div className={styles.container} data-flx="auth.accounts.account-selector.container">
|
||||
@@ -142,38 +145,36 @@ export const AccountSelector = observer(
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.accountListWrapper} data-flx="auth.accounts.account-selector.account-list-wrapper">
|
||||
{accounts.length === 0 ? (
|
||||
<div className={styles.noAccounts} data-flx="auth.accounts.account-selector.no-accounts">
|
||||
<Trans>No accounts</Trans>
|
||||
{accounts.length === 0 ? (
|
||||
<div className={styles.noAccounts} data-flx="auth.accounts.account-selector.no-accounts">
|
||||
<Trans>No accounts</Trans>
|
||||
</div>
|
||||
) : (
|
||||
<Scroller
|
||||
className={styles.scroller}
|
||||
key={scrollerKey ?? 'account-selector-scroller'}
|
||||
data-flx="auth.accounts.account-selector.scroller"
|
||||
>
|
||||
<div className={styles.accountList} data-flx="auth.accounts.account-selector.account-list">
|
||||
{accounts.map((account) => {
|
||||
const isCurrent = account.userId === currentAccountId;
|
||||
return (
|
||||
<AccountRow
|
||||
key={account.userId}
|
||||
account={account}
|
||||
variant="manage"
|
||||
isCurrent={isCurrent}
|
||||
isExpired={account.isValid === false}
|
||||
onClick={clickableRows && !disabled ? () => onSelectAccount(account) : undefined}
|
||||
showCaretIndicator={clickableRows && isCurrent}
|
||||
onMenuClick={isCurrent ? undefined : openMenu(account)}
|
||||
data-flx="auth.accounts.account-selector.account-row.select-account"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Scroller
|
||||
className={styles.scroller}
|
||||
key={scrollerKey ?? 'account-selector-scroller'}
|
||||
data-flx="auth.accounts.account-selector.scroller"
|
||||
>
|
||||
<div className={styles.accountList} data-flx="auth.accounts.account-selector.account-list">
|
||||
{accounts.map((account) => {
|
||||
const isCurrent = account.userId === currentAccountId;
|
||||
return (
|
||||
<AccountRow
|
||||
key={account.userId}
|
||||
account={account}
|
||||
variant="manage"
|
||||
isCurrent={isCurrent}
|
||||
isExpired={account.isValid === false}
|
||||
onClick={clickableRows && !disabled ? () => onSelectAccount(account) : undefined}
|
||||
showCaretIndicator={clickableRows}
|
||||
onMenuClick={!clickableRows && !disabled ? openMenu(account) : undefined}
|
||||
data-flx="auth.accounts.account-selector.account-row.select-account"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Scroller>
|
||||
)}
|
||||
</div>
|
||||
</Scroller>
|
||||
)}
|
||||
{onAddAccount && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
|
||||
@@ -66,6 +66,7 @@ const LoginPageMFA = observer(function LoginPageMFA() {
|
||||
const handleMfaSuccess = useCallback(
|
||||
async (payload: LoginSuccessPayload) => {
|
||||
if (isHandoff) {
|
||||
await AccountManager.refreshStoredAccount(payload.userId, payload.token, payload.userData);
|
||||
await handoff.start(payload);
|
||||
return;
|
||||
} else {
|
||||
|
||||
@@ -50,7 +50,7 @@ import {SteppedCarousel} from '@app/features/ui/stepped_carousel/SteppedCarousel
|
||||
import {isDesktop} from '@app/features/ui/utils/NativeUtils';
|
||||
import * as FormUtils from '@app/lib/forms';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import clsx from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {cloneElement, type ReactElement, type ReactNode, useCallback, useEffect, useMemo, useState} from 'react';
|
||||
@@ -151,14 +151,19 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
const [switchError, setSwitchError] = useState<string | null>(null);
|
||||
const [prefillEmail, setPrefillEmail] = useState<string | null>(() => initialEmail ?? null);
|
||||
const ssoRedirectPath = desktopHandoff ? `${location.pathname}${location.search}` : redirectPath;
|
||||
const showLoginFormForAccount = useCallback((account: Account, message?: string | null) => {
|
||||
setShowAccountSelector(false);
|
||||
setSwitchError(message ?? null);
|
||||
setPrefillEmail(account.userData?.email ?? null);
|
||||
}, []);
|
||||
const showExpiredLoginForm = useCallback(
|
||||
(account: Account) => {
|
||||
const identifier = account.userData?.email ?? account.userData?.username ?? account.userId;
|
||||
setShowAccountSelector(false);
|
||||
setSwitchError(i18n._(SESSION_EXPIRED_SIGN_IN_AGAIN_DESCRIPTOR, {identifier}));
|
||||
setPrefillEmail(account.userData?.email ?? null);
|
||||
},
|
||||
[i18n],
|
||||
);
|
||||
const handleLoginSuccess = useCallback(
|
||||
async (payload: LoginSuccessPayload) => {
|
||||
if (desktopHandoff) {
|
||||
await AccountManager.refreshStoredAccount(payload.userId, payload.token, payload.userData);
|
||||
await handoff.start(payload);
|
||||
return;
|
||||
}
|
||||
@@ -235,20 +240,26 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
}, [form.setValue, prefillEmail]);
|
||||
const handleSelectExistingAccount = useCallback(
|
||||
async (account: Account) => {
|
||||
const identifier = account.userData?.email ?? account.userData?.username ?? account.userId;
|
||||
const expiredMessage = i18n._(SESSION_EXPIRED_SIGN_IN_AGAIN_DESCRIPTOR, {identifier});
|
||||
if (account.isValid === false || !AccountManager.canSwitchAccounts) {
|
||||
showLoginFormForAccount(account, expiredMessage);
|
||||
if (account.isValid === false) {
|
||||
showExpiredLoginForm(account);
|
||||
return;
|
||||
}
|
||||
setIsSwitching(true);
|
||||
setSwitchError(null);
|
||||
try {
|
||||
await AccountManager.switchToAccount(account.userId);
|
||||
if (AccountManager.canSwitchAccounts) {
|
||||
await AccountManager.switchToAccount(account.userId);
|
||||
} else {
|
||||
const {token, userId} = await AccountManager.generateTokenForAccount(account.userId);
|
||||
await handleLoginSuccess({token, userId, userData: account.userData});
|
||||
if (redirectPath) {
|
||||
RouterUtils.replaceWith(redirectPath);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const updatedAccount = AccountManager.accounts.get(account.userId);
|
||||
if (error instanceof SessionExpiredError || updatedAccount?.isValid === false) {
|
||||
showLoginFormForAccount(updatedAccount ?? account, expiredMessage);
|
||||
showExpiredLoginForm(updatedAccount ?? account);
|
||||
return;
|
||||
}
|
||||
setSwitchError(
|
||||
@@ -260,8 +271,26 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
setIsSwitching(false);
|
||||
}
|
||||
},
|
||||
[i18n, showLoginFormForAccount],
|
||||
[handleLoginSuccess, i18n, redirectPath, showExpiredLoginForm],
|
||||
);
|
||||
const handleHandoffReLogin = useCallback(
|
||||
(account: Account) => {
|
||||
showExpiredLoginForm(account);
|
||||
handoff.switchToLogin();
|
||||
},
|
||||
[handoff, showExpiredLoginForm],
|
||||
);
|
||||
const canChooseSavedAccount = desktopHandoff ? hasHandoffAccounts : hasStoredAccounts;
|
||||
const handleChooseSavedAccount = useCallback(() => {
|
||||
setSwitchError(null);
|
||||
setPrefillEmail(initialEmail ?? null);
|
||||
form.setValue('email', initialEmail ?? '');
|
||||
if (desktopHandoff) {
|
||||
handoff.setMode('selecting');
|
||||
} else {
|
||||
setShowAccountSelector(true);
|
||||
}
|
||||
}, [desktopHandoff, form.setValue, handoff, initialEmail]);
|
||||
const handleAddAnotherAccount = useCallback(() => {
|
||||
setShowAccountSelector(false);
|
||||
setSwitchError(null);
|
||||
@@ -292,13 +321,21 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
});
|
||||
}, [registerLink]);
|
||||
const authLoginStep: AuthLoginStep = useMemo(() => {
|
||||
if (desktopHandoff && handoff.mode === 'selecting') return 'desktop_handoff_account';
|
||||
if (desktopHandoff && handoff.mode === 'selecting' && hasHandoffAccounts) return 'desktop_handoff_account';
|
||||
if (desktopHandoff && isApprovalFlowMode(handoff.mode)) return 'desktop_handoff_approval';
|
||||
if (isSsoEnforced) return 'sso';
|
||||
if (showAccountSelector && hasStoredAccounts && !desktopHandoff) return 'account';
|
||||
if (ipAuthChallenge) return 'ip_authorization';
|
||||
return 'credentials';
|
||||
}, [desktopHandoff, handoff.mode, hasStoredAccounts, ipAuthChallenge, isSsoEnforced, showAccountSelector]);
|
||||
}, [
|
||||
desktopHandoff,
|
||||
handoff.mode,
|
||||
hasHandoffAccounts,
|
||||
hasStoredAccounts,
|
||||
ipAuthChallenge,
|
||||
isSsoEnforced,
|
||||
showAccountSelector,
|
||||
]);
|
||||
const hasExtraTopContent = extraTopContent !== undefined && extraTopContent !== null;
|
||||
const startedFromAccountSelector = hasStoredAccounts && !desktopHandoff && !initialEmail;
|
||||
const showSplitLogo =
|
||||
@@ -316,6 +353,7 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
<DesktopHandoffAccountSelector
|
||||
excludeCurrentUser={excludeCurrentUser}
|
||||
onSelectNewAccount={handoff.switchToLogin}
|
||||
onReLoginAccount={handleHandoffReLogin}
|
||||
onAccountSelected={handoff.start}
|
||||
data-flx="auth.flow.auth-login-layout.desktop-handoff-account-selector"
|
||||
/>
|
||||
@@ -443,6 +481,18 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
data-flx="auth.flow.auth-login-layout.auth-login-passkey-actions"
|
||||
/>
|
||||
<div className={styles.footer} data-flx="auth.flow.auth-login-layout.footer">
|
||||
{canChooseSavedAccount ? (
|
||||
<div className={styles.footerText} data-flx="auth.flow.auth-login-layout.footer-text--choose-account">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.footerLink}
|
||||
onClick={handleChooseSavedAccount}
|
||||
data-flx="auth.flow.auth-login-layout.button.choose-saved-account"
|
||||
>
|
||||
<Trans>Choose an account</Trans>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.footerText} data-flx="auth.flow.auth-login-layout.footer-text">
|
||||
<span className={styles.footerLabel} data-flx="auth.flow.auth-login-layout.footer-label">
|
||||
{i18n._(NEED_ACCOUNT_DESCRIPTOR)}{' '}
|
||||
|
||||
@@ -9,11 +9,6 @@ import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useCallback, useState} from 'react';
|
||||
|
||||
const SESSION_EXPIRED_PLEASE_SIGN_IN_AGAIN_DESCRIPTOR = msg({
|
||||
message: 'Session expired. Sign in again.',
|
||||
comment:
|
||||
'Toast error shown in the desktop-handoff account picker when the session for the chosen account has expired.',
|
||||
});
|
||||
const FAILED_TO_GENERATE_TOKEN_DESCRIPTOR = msg({
|
||||
message: 'Failed to generate token',
|
||||
comment: 'Short label in the authentication desktop handoff account selector. Keep the tone plain and specific.',
|
||||
@@ -22,12 +17,14 @@ const FAILED_TO_GENERATE_TOKEN_DESCRIPTOR = msg({
|
||||
interface DesktopHandoffAccountSelectorProps {
|
||||
excludeCurrentUser?: boolean;
|
||||
onSelectNewAccount: () => void;
|
||||
onReLoginAccount: (account: Account) => void;
|
||||
onAccountSelected: (payload: {token: string; userId: string}) => void;
|
||||
}
|
||||
|
||||
const DesktopHandoffAccountSelector = observer(function DesktopHandoffAccountSelector({
|
||||
excludeCurrentUser = false,
|
||||
onSelectNewAccount,
|
||||
onReLoginAccount,
|
||||
onAccountSelected,
|
||||
}: DesktopHandoffAccountSelectorProps) {
|
||||
const {i18n} = useLingui();
|
||||
@@ -38,6 +35,10 @@ const DesktopHandoffAccountSelector = observer(function DesktopHandoffAccountSel
|
||||
const accounts = excludeCurrentUser ? allAccounts.filter((account) => account.userId !== currentUserId) : allAccounts;
|
||||
const handleSelectAccount = useCallback(
|
||||
async (account: Account) => {
|
||||
if (account.isValid === false) {
|
||||
onReLoginAccount(account);
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -48,7 +49,7 @@ const DesktopHandoffAccountSelector = observer(function DesktopHandoffAccountSel
|
||||
onAccountSelected({token, userId});
|
||||
} catch (err) {
|
||||
if (err instanceof SessionExpiredError) {
|
||||
setError(i18n._(SESSION_EXPIRED_PLEASE_SIGN_IN_AGAIN_DESCRIPTOR));
|
||||
onReLoginAccount(AccountManager.accounts.get(account.userId) ?? account);
|
||||
} else {
|
||||
setError(
|
||||
err && typeof err === 'object' && 'body' in err
|
||||
@@ -60,11 +61,12 @@ const DesktopHandoffAccountSelector = observer(function DesktopHandoffAccountSel
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[onAccountSelected, i18n],
|
||||
[onAccountSelected, onReLoginAccount, i18n],
|
||||
);
|
||||
return (
|
||||
<AccountSelector
|
||||
accounts={accounts}
|
||||
currentAccountId={currentUserId}
|
||||
title={<Trans>Choose an account</Trans>}
|
||||
description={<Trans>Select the account you want to sign in with on your new device.</Trans>}
|
||||
disabled={isLoading}
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import type {UserData} from '@app/features/auth/state/AccountStorage';
|
||||
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
|
||||
import * as NotificationUtils from '@app/features/notification/utils/NotificationUtils';
|
||||
import * as PushSubscriptionService from '@app/features/platform/push/PushSubscriptionService';
|
||||
import SessionManager, {type Account, SessionExpiredError} from '@app/features/platform/state/AuthSession';
|
||||
import SessionManager, {type Account} from '@app/features/platform/state/AuthSession';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {isInstalledPwa} from '@app/features/ui/utils/PwaUtils';
|
||||
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
||||
@@ -15,6 +16,8 @@ import {computed, makeAutoObservable} from 'mobx';
|
||||
const logger = new Logger('AccountManager');
|
||||
|
||||
class AccountManager {
|
||||
transitioning = false;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
@@ -82,16 +85,27 @@ class AccountManager {
|
||||
token: string;
|
||||
userId: string;
|
||||
}> {
|
||||
await SessionManager.initialize();
|
||||
const account = SessionManager.requireAccountOnCurrentInstance(userId);
|
||||
const ok = await SessionManager.validateToken(account.token);
|
||||
if (!ok) {
|
||||
SessionManager.markAccountInvalid(userId);
|
||||
throw new SessionExpiredError();
|
||||
}
|
||||
const account = await SessionManager.requireUsableAccount(userId);
|
||||
return {token: account.token, userId};
|
||||
}
|
||||
|
||||
async refreshStoredAccount(userId: string, token: string, userData?: UserData): Promise<void> {
|
||||
await SessionManager.refreshStoredAccount(userId, token, userData);
|
||||
}
|
||||
|
||||
private setTransitioning(value: boolean): void {
|
||||
this.transitioning = value;
|
||||
}
|
||||
|
||||
private async runTransition(run: () => Promise<void>): Promise<void> {
|
||||
this.setTransitioning(true);
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
this.setTransitioning(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async leaveActiveVoiceChannel(context: 'account switch' | 'logout'): Promise<void> {
|
||||
try {
|
||||
await MediaEngine.disconnectFromVoiceChannel('user');
|
||||
@@ -108,11 +122,14 @@ class AccountManager {
|
||||
if (!SessionManager.canSwitchAccount()) {
|
||||
throw new Error(`Cannot switch from state: ${SessionManager.state}`);
|
||||
}
|
||||
await SessionManager.requireUsableAccount(userId);
|
||||
if (this.shouldManagePushSubscriptions()) {
|
||||
await PushSubscriptionService.unregisterAllPushSubscriptions();
|
||||
}
|
||||
await this.leaveActiveVoiceChannel('account switch');
|
||||
await SessionManager.switchAccount(userId);
|
||||
await this.runTransition(async () => {
|
||||
await this.leaveActiveVoiceChannel('account switch');
|
||||
await SessionManager.switchAccount(userId);
|
||||
});
|
||||
GatewayConnection.startSession(SessionManager.token ?? undefined);
|
||||
if (redirectPath !== null) {
|
||||
RouterUtils.replaceWith(redirectPath);
|
||||
@@ -135,8 +152,10 @@ class AccountManager {
|
||||
if (SessionManager.userId && SessionManager.userId !== userId) {
|
||||
SessionManager.prepareForAccountTransition('account-switch');
|
||||
}
|
||||
await this.leaveActiveVoiceChannel('account switch');
|
||||
await SessionManager.login(token, userId, userData);
|
||||
await this.runTransition(async () => {
|
||||
await this.leaveActiveVoiceChannel('account switch');
|
||||
await SessionManager.login(token, userId, userData);
|
||||
});
|
||||
GatewayConnection.startSession(token);
|
||||
if (redirectPath !== null) {
|
||||
RouterUtils.replaceWith(redirectPath);
|
||||
@@ -159,8 +178,11 @@ class AccountManager {
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
await this.leaveActiveVoiceChannel('logout');
|
||||
await SessionManager.logout();
|
||||
await this.runTransition(async () => {
|
||||
await this.leaveActiveVoiceChannel('logout');
|
||||
await SessionManager.logout();
|
||||
});
|
||||
ExperimentAssignments.reset();
|
||||
RouterUtils.replaceWith('/login');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {isDomainMigrationStorageKey} from '@app/features/app/domain_migration/DomainMigrationCore';
|
||||
import {
|
||||
normalizeAppPublicConfig,
|
||||
normalizeInstanceRegistration,
|
||||
@@ -69,7 +70,7 @@ const MANAGED_KEY_PREFIXES: ReadonlyArray<string> = ['mobx', 'mobx-persist', 'pe
|
||||
const MANAGED_KEY_PREFIX_PATTERN = new RegExp(`^(?:${MANAGED_KEY_PREFIXES.join('|')})`);
|
||||
|
||||
function isManagedKey(key: string): boolean {
|
||||
if (!key) {
|
||||
if (!key || isDomainMigrationStorageKey(key)) {
|
||||
return false;
|
||||
}
|
||||
return MANAGED_KEY_EXACT.has(key) || MANAGED_KEY_PREFIX_PATTERN.test(key);
|
||||
@@ -196,6 +197,9 @@ class AccountStorage {
|
||||
}
|
||||
const keysToRemove = collectManagedKeys(browserLocalStorage).filter((key) => snapshot[key] === undefined);
|
||||
for (const [key, value] of Object.entries(snapshot)) {
|
||||
if (isDomainMigrationStorageKey(key)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
browserLocalStorage.setItem(key, value);
|
||||
} catch (err) {
|
||||
@@ -500,14 +504,30 @@ class AccountStorage {
|
||||
}
|
||||
}
|
||||
|
||||
async updateAccountValidity(userId: string, isValid: boolean): Promise<void> {
|
||||
async refreshAccountCredentials(userId: string, token: string, userData?: UserData): Promise<void> {
|
||||
await this.ensureDb();
|
||||
if (!userId || !token) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const record = await this.getRecord(userId);
|
||||
if (!record) {
|
||||
return;
|
||||
}
|
||||
await this.putRecord({...record, token, userData: userData ?? record.userData, isValid: true});
|
||||
} catch (err) {
|
||||
logger.error(`Failed to refresh credentials for account ${userId}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
async updateAccountValidity(userId: string, isValid: boolean, expectedToken?: string): Promise<void> {
|
||||
await this.ensureDb();
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const record = await this.getRecord(userId);
|
||||
if (!record) {
|
||||
if (!record || (expectedToken !== undefined && record.token !== expectedToken)) {
|
||||
return;
|
||||
}
|
||||
await this.putRecord({...record, isValid});
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import type {DomainMigrationMediaDeviceKind} from '@app/features/app/domain_migration/DomainMigrationCore';
|
||||
import {remapMigratedDeviceIds} from '@app/features/app/domain_migration/DomainMigrationDeviceRemap';
|
||||
import styles from '@app/features/auth/state/NewDeviceMonitoring.module.css';
|
||||
import {
|
||||
getNewDevicePromptCandidates,
|
||||
getRealDeviceIds,
|
||||
type PendingDevicePrompt,
|
||||
} from '@app/features/auth/state/NewDeviceMonitoringDevices';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
@@ -32,6 +35,9 @@ const NOT_NOW_DESCRIPTOR = msg({
|
||||
message: 'Not now',
|
||||
comment: 'Short label in the authentication new device monitoring. Keep the tone plain and specific.',
|
||||
});
|
||||
const NEW_DEVICE_MODAL_KEY = 'new-audio-device';
|
||||
const MAX_PROMPTABLE_NEW_DEVICE_GROUPS = 2;
|
||||
const MIGRATED_AUDIO_DEVICE_KINDS: ReadonlyArray<DomainMigrationMediaDeviceKind> = ['audioinput', 'audiooutput'];
|
||||
const logger = new Logger('NewDeviceMonitoring');
|
||||
|
||||
interface IgnoreDeviceLinkProps {
|
||||
@@ -60,8 +66,6 @@ class NewDeviceMonitoring {
|
||||
private isStarted = false;
|
||||
private startPromise: Promise<void> | null = null;
|
||||
private startEpoch = 0;
|
||||
private pendingPrompts: Array<PendingDevicePrompt> = [];
|
||||
private isShowingPrompt = false;
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
private i18n: I18n | null = null;
|
||||
|
||||
@@ -91,7 +95,9 @@ class NewDeviceMonitoring {
|
||||
this.isStarted = true;
|
||||
const epoch = ++this.startEpoch;
|
||||
this.startPromise = (async () => {
|
||||
await makePersistent(this, 'NewDeviceMonitoring', ['knownDeviceIds', 'ignoredDeviceIds', 'suppressAlerts']);
|
||||
await makePersistent(this, 'NewDeviceMonitoring', ['knownDeviceIds', 'ignoredDeviceIds', 'suppressAlerts'], {
|
||||
syncAcrossTabs: true,
|
||||
});
|
||||
if (!this.isStarted || epoch !== this.startEpoch) return;
|
||||
await this.refreshDeviceSnapshot();
|
||||
if (!this.isStarted || epoch !== this.startEpoch) return;
|
||||
@@ -102,6 +108,12 @@ class NewDeviceMonitoring {
|
||||
|
||||
private handleDeviceStateChange(state: VoiceDeviceState): void {
|
||||
if (!this.isStarted) return;
|
||||
const ignoredDeviceIds = remapMigratedDeviceIds(this.ignoredDeviceIds, MIGRATED_AUDIO_DEVICE_KINDS);
|
||||
if (ignoredDeviceIds !== this.ignoredDeviceIds) {
|
||||
runInAction(() => {
|
||||
this.ignoredDeviceIds = ignoredDeviceIds;
|
||||
});
|
||||
}
|
||||
if (state.permissionStatus.audio !== 'granted') {
|
||||
return;
|
||||
}
|
||||
@@ -112,27 +124,34 @@ class NewDeviceMonitoring {
|
||||
const currentOutputIds = state.outputDevices.map((d) => d.deviceId);
|
||||
const allCurrentIds = [...currentInputIds, ...currentOutputIds];
|
||||
if (!this.isInitialized) {
|
||||
const promptCandidates =
|
||||
this.knownDeviceIds.length > 0
|
||||
? getNewDevicePromptCandidates(state, this.knownDeviceIds, this.ignoredDeviceIds, {
|
||||
inputDeviceId: VoiceSettings.getInputDeviceId(),
|
||||
outputDeviceId: VoiceSettings.getOutputDeviceId(),
|
||||
})
|
||||
: [];
|
||||
const knownDeviceIdSet = new Set(this.knownDeviceIds);
|
||||
const realDeviceIds = getRealDeviceIds(state);
|
||||
const isFreshIdSpace =
|
||||
realDeviceIds.length > 0 && !realDeviceIds.some((deviceId) => knownDeviceIdSet.has(deviceId));
|
||||
const knownDeviceIds = isFreshIdSpace
|
||||
? this.knownDeviceIds
|
||||
: remapMigratedDeviceIds(this.knownDeviceIds, MIGRATED_AUDIO_DEVICE_KINDS);
|
||||
const promptCandidates = isFreshIdSpace
|
||||
? []
|
||||
: getNewDevicePromptCandidates(state, knownDeviceIds, this.ignoredDeviceIds, {
|
||||
inputDeviceId: VoiceSettings.getInputDeviceId(),
|
||||
outputDeviceId: VoiceSettings.getOutputDeviceId(),
|
||||
});
|
||||
runInAction(() => {
|
||||
if (promptCandidates.length > 0) {
|
||||
this.pendingPrompts.push(...promptCandidates);
|
||||
this.knownDeviceIds = isFreshIdSpace
|
||||
? [...new Set(allCurrentIds)]
|
||||
: [...new Set([...knownDeviceIds, ...allCurrentIds])];
|
||||
if (ignoredDeviceIds !== this.ignoredDeviceIds) {
|
||||
this.ignoredDeviceIds = ignoredDeviceIds;
|
||||
}
|
||||
this.knownDeviceIds = [...new Set([...this.knownDeviceIds, ...allCurrentIds])];
|
||||
this.isInitialized = true;
|
||||
});
|
||||
logger.debug('Initialized with known devices', {
|
||||
count: this.knownDeviceIds.length,
|
||||
isFreshIdSpace,
|
||||
promptCount: promptCandidates.length,
|
||||
});
|
||||
if (promptCandidates.length > 0) {
|
||||
this.processNextPrompt();
|
||||
}
|
||||
this.promptForNewDevice(promptCandidates);
|
||||
return;
|
||||
}
|
||||
const promptCandidates = getNewDevicePromptCandidates(state, this.knownDeviceIds, this.ignoredDeviceIds, {
|
||||
@@ -140,9 +159,6 @@ class NewDeviceMonitoring {
|
||||
outputDeviceId: VoiceSettings.getOutputDeviceId(),
|
||||
});
|
||||
runInAction(() => {
|
||||
if (promptCandidates.length > 0) {
|
||||
this.pendingPrompts.push(...promptCandidates);
|
||||
}
|
||||
this.knownDeviceIds = [...new Set([...this.knownDeviceIds, ...allCurrentIds])];
|
||||
});
|
||||
if (promptCandidates.length > 0) {
|
||||
@@ -153,21 +169,17 @@ class NewDeviceMonitoring {
|
||||
deviceType: prompt.deviceType,
|
||||
})),
|
||||
});
|
||||
this.processNextPrompt();
|
||||
}
|
||||
this.promptForNewDevice(promptCandidates);
|
||||
}
|
||||
|
||||
private processNextPrompt(): void {
|
||||
if (!this.isStarted) return;
|
||||
if (this.isShowingPrompt || this.pendingPrompts.length === 0) {
|
||||
private promptForNewDevice(promptCandidates: ReadonlyArray<PendingDevicePrompt>): void {
|
||||
if (promptCandidates.length === 0 || promptCandidates.length > MAX_PROMPTABLE_NEW_DEVICE_GROUPS) {
|
||||
return;
|
||||
}
|
||||
const prompt = this.pendingPrompts.shift();
|
||||
if (!prompt) {
|
||||
return;
|
||||
}
|
||||
this.isShowingPrompt = true;
|
||||
this.showNewDeviceModal(prompt);
|
||||
this.showNewDeviceModal(
|
||||
promptCandidates.find((prompt) => prompt.inputDeviceId !== undefined) ?? promptCandidates[0],
|
||||
);
|
||||
}
|
||||
|
||||
private showNewDeviceModal(prompt: PendingDevicePrompt): void {
|
||||
@@ -176,7 +188,7 @@ class NewDeviceMonitoring {
|
||||
}
|
||||
const i18n = this.i18n;
|
||||
const {deviceIds, deviceName, deviceType, inputDeviceId, outputDeviceId} = prompt;
|
||||
ModalCommands.push(
|
||||
ModalCommands.pushWithKey(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={i18n._(NEW_AUDIO_DEVICE_DETECTED_DESCRIPTOR)}
|
||||
@@ -208,8 +220,7 @@ class NewDeviceMonitoring {
|
||||
deviceName={deviceName}
|
||||
onClick={() => {
|
||||
this.addToIgnored(deviceIds);
|
||||
ModalCommands.pop();
|
||||
setTimeout(() => this.onModalClosed(), 0);
|
||||
ModalCommands.popWithKey(NEW_DEVICE_MODAL_KEY);
|
||||
}}
|
||||
data-flx="auth.new-device-monitoring.ignore-device-link.add-to-ignored"
|
||||
/>
|
||||
@@ -225,25 +236,19 @@ class NewDeviceMonitoring {
|
||||
if (dontAskAgain) {
|
||||
this.addToIgnored(deviceIds);
|
||||
}
|
||||
setTimeout(() => this.onModalClosed(), 0);
|
||||
}}
|
||||
onSecondary={(dontAskAgain) => {
|
||||
if (dontAskAgain) {
|
||||
this.addToIgnored(deviceIds);
|
||||
}
|
||||
setTimeout(() => this.onModalClosed(), 0);
|
||||
}}
|
||||
data-flx="auth.new-device-monitoring.confirm-modal"
|
||||
/>
|
||||
)),
|
||||
NEW_DEVICE_MODAL_KEY,
|
||||
);
|
||||
}
|
||||
|
||||
private onModalClosed(): void {
|
||||
this.isShowingPrompt = false;
|
||||
this.processNextPrompt();
|
||||
}
|
||||
|
||||
private addToIgnored(deviceIds: string | ReadonlyArray<string>): void {
|
||||
const ids = typeof deviceIds === 'string' ? [deviceIds] : deviceIds;
|
||||
const newDeviceIds = ids.filter((deviceId) => !this.ignoredDeviceIds.includes(deviceId));
|
||||
@@ -290,8 +295,6 @@ class NewDeviceMonitoring {
|
||||
this.isStarted = false;
|
||||
this.startPromise = null;
|
||||
this.startEpoch++;
|
||||
this.pendingPrompts = [];
|
||||
this.isShowingPrompt = false;
|
||||
if (this.unsubscribe) {
|
||||
this.unsubscribe();
|
||||
this.unsubscribe = null;
|
||||
|
||||
@@ -59,6 +59,12 @@ function isVirtualRouteDevice(device: MediaDeviceInfo): boolean {
|
||||
return getVoiceAudioDeviceMetadata(device) !== null;
|
||||
}
|
||||
|
||||
export function getRealDeviceIds(state: VoiceDeviceState): Array<string> {
|
||||
return [...state.inputDevices, ...state.outputDevices]
|
||||
.filter((device) => !isVirtualRouteDevice(device) && device.deviceId.trim().length > 0)
|
||||
.map((device) => device.deviceId);
|
||||
}
|
||||
|
||||
function isPromptableDevice(device: MediaDeviceInfo, context: PromptableDeviceContext): boolean {
|
||||
if (isVirtualRouteDevice(device)) {
|
||||
return false;
|
||||
|
||||
Reference in new issue
Block a user