From 3c8fe7466008ee1930aff090b96b403177f58dda Mon Sep 17 00:00:00 2001 From: Hampus Date: Wed, 14 Oct 2026 15:39:33 +0200 Subject: [PATCH] fix(app): refetch oauth bot destinations after account switch (#3460) --- .../auth/commands/AuthenticationCommands.ts | 2 +- .../auth/components/pages/LoginPage.tsx | 2 +- .../OAuthCommunityStep.tsx | 2 +- .../hooks/useBotInviteDestinations.ts | 35 ++++++++++++++++--- .../hooks/useOAuthPublicApp.ts | 10 ++++-- .../state/useAuthorizeFlow.ts | 29 +++++++-------- .../features/auth/flow/AuthLoginLayout.tsx | 17 +++------ .../features/auth/flow/ResourceAuthPages.tsx | 3 +- .../components/pages/InviteLoginPage.tsx | 2 +- 9 files changed, 62 insertions(+), 40 deletions(-) diff --git a/fluxer_app/src/features/auth/commands/AuthenticationCommands.ts b/fluxer_app/src/features/auth/commands/AuthenticationCommands.ts index 99585a83f..f00e267f3 100644 --- a/fluxer_app/src/features/auth/commands/AuthenticationCommands.ts +++ b/fluxer_app/src/features/auth/commands/AuthenticationCommands.ts @@ -769,7 +769,7 @@ export async function fetchDesktopHandoffInfo({ return response.body; } -export interface CompleteLoginOptions { +interface CompleteLoginOptions { redirectPath?: string | null; } diff --git a/fluxer_app/src/features/auth/components/pages/LoginPage.tsx b/fluxer_app/src/features/auth/components/pages/LoginPage.tsx index f0912cbcb..40752c157 100644 --- a/fluxer_app/src/features/auth/components/pages/LoginPage.tsx +++ b/fluxer_app/src/features/auth/components/pages/LoginPage.tsx @@ -64,7 +64,7 @@ const LoginPage = observer(function LoginPage({onBackActionChange}: LoginPagePro title={null} onLoginComplete={runtimeTarget.reset} onBackActionChange={onBackActionChange} - completeLoginRedirectPath={null} + completeLoginRedirectPath={Routes.ME} forceCredentials={false} startWithAddAccount={false} runtimeTarget={runtimeTarget} diff --git a/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/OAuthCommunityStep.tsx b/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/OAuthCommunityStep.tsx index 5178bae63..e1d1547b6 100644 --- a/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/OAuthCommunityStep.tsx +++ b/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/OAuthCommunityStep.tsx @@ -150,7 +150,7 @@ export const OAuthCommunityStep: React.FC = ({ {destinationsError} )} - {!destinationsLoading && destinationOptions.length === 0 && ( + {!destinationsLoading && !destinationsError && destinationOptions.length === 0 && (
{guildSelectorEmptyState}
diff --git a/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/hooks/useBotInviteDestinations.ts b/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/hooks/useBotInviteDestinations.ts index 51530a600..80fc2e8c0 100644 --- a/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/hooks/useBotInviteDestinations.ts +++ b/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/hooks/useBotInviteDestinations.ts @@ -10,6 +10,8 @@ import { } from '@app/features/auth/components/pages/oauth_authorize_page/OAuthAuthorizePageShared'; import Accounts from '@app/features/auth/state/Accounts'; import {formatList} from '@app/features/i18n/utils/IntlCache'; +import {AccountScopedWork} from '@app/features/platform/state/AccountScopedWork'; +import {isAccountTransitionAbortError} from '@app/features/platform/state/AccountTransitionAbort'; import {http} from '@app/features/platform/transport/RestTransport'; import {failureMessage} from '@app/features/platform/utils/ResponseInspection'; import * as AvatarUtils from '@app/features/user/utils/AvatarUtils'; @@ -106,9 +108,14 @@ function getGroupDmLabel(channel: ChannelResponse, unknownGroupDmLabel: string, return unknownGroupDmLabel; } -export function useBotInviteDestinations(enabled: boolean, rawPermissions: string | null): BotInviteDestinationsResult { +export function useBotInviteDestinations( + enabled: boolean, + rawPermissions: string | null, + revalidate: boolean, +): BotInviteDestinationsResult { const {i18n} = useLingui(); const currentAccountKey = Accounts.currentAccountKey; + const accountWorkAdmitted = !AccountScopedWork.isSuspended; const requestedPermissions = useMemo(() => parseRequestedBotPermissions(rawPermissions, false), [rawPermissions]); const i18nRef = useRef(i18n); i18nRef.current = i18n; @@ -116,17 +123,32 @@ export function useBotInviteDestinations(enabled: boolean, rawPermissions: strin const [rawGuilds, setRawGuilds] = useState>([]); const [rawChannels, setRawChannels] = useState>([]); const [error, setError] = useState(null); + const [revision, setRevision] = useState(0); + const loadedAccountKeyRef = useRef(null); + useEffect(() => { + if (!revalidate) return; + const bumpRevision = () => setRevision((value) => value + 1); + bumpRevision(); + window.addEventListener('focus', bumpRevision); + return () => { + window.removeEventListener('focus', bumpRevision); + }; + }, [revalidate]); useEffect(() => { if (!enabled) { + loadedAccountKeyRef.current = null; setStatus('idle'); setRawGuilds([]); setRawChannels([]); setError(null); return; } + if (loadedAccountKeyRef.current !== currentAccountKey) { + setStatus('loading'); + setError(null); + } + if (!accountWorkAdmitted) return; let cancelled = false; - setStatus('loading'); - setError(null); (async () => { try { const [guildsResp, channelsResp] = await Promise.all([ @@ -136,10 +158,13 @@ export function useBotInviteDestinations(enabled: boolean, rawPermissions: strin if (cancelled) return; setRawGuilds(guildsResp.body); setRawChannels(channelsResp.body.filter((channel) => channel.type === ChannelTypes.GROUP_DM)); + loadedAccountKeyRef.current = currentAccountKey; + setError(null); setStatus('ready'); } catch (err) { - if (cancelled) return; + if (cancelled || isAccountTransitionAbortError(err)) return; logger.error('Failed to fetch bot invite destinations', err); + loadedAccountKeyRef.current = null; setError(failureMessage(err) ?? i18nRef.current._(FAILED_TO_LOAD_INVITE_DESTINATIONS_DESCRIPTOR)); setRawGuilds([]); setRawChannels([]); @@ -149,7 +174,7 @@ export function useBotInviteDestinations(enabled: boolean, rawPermissions: strin return () => { cancelled = true; }; - }, [enabled, currentAccountKey]); + }, [enabled, currentAccountKey, accountWorkAdmitted, revision]); const guilds = useMemo>( () => mapBotGuilds(rawGuilds, requestedPermissions, i18n._(UNKNOWN_COMMUNITY_DESCRIPTOR)), [rawGuilds, requestedPermissions, i18n.locale], diff --git a/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/hooks/useOAuthPublicApp.ts b/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/hooks/useOAuthPublicApp.ts index 39d902e98..1734d0ddf 100644 --- a/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/hooks/useOAuthPublicApp.ts +++ b/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/hooks/useOAuthPublicApp.ts @@ -8,6 +8,8 @@ import { } from '@app/features/auth/components/pages/oauth_authorize_page/OAuthAuthorizePageShared'; import accountStorage from '@app/features/auth/state/AccountStorage'; import Accounts from '@app/features/auth/state/Accounts'; +import {AccountScopedWork} from '@app/features/platform/state/AccountScopedWork'; +import {isAccountTransitionAbortError} from '@app/features/platform/state/AccountTransitionAbort'; import {http} from '@app/features/platform/transport/RestTransport'; import {HttpError} from '@app/features/platform/types/EndpointError'; import {useEffect, useState} from 'react'; @@ -23,6 +25,7 @@ export interface PublicAppState { export function useOAuthPublicApp(clientId: string | null): PublicAppState { const currentUserId = Accounts.currentUserId; const currentAccountKey = Accounts.currentAccountKey; + const accountWorkAdmitted = !AccountScopedWork.isSuspended; const [state, setState] = useState(() => ({ status: clientId ? 'loading' : 'idle', data: null, @@ -33,8 +36,9 @@ export function useOAuthPublicApp(clientId: string | null): PublicAppState { setState({status: 'idle', data: null, error: null}); return; } - let cancelled = false; setState({status: 'loading', data: null, error: null}); + if (!accountWorkAdmitted) return; + let cancelled = false; (async () => { try { const resp = await http.get(Endpoints.OAUTH_PUBLIC_APPLICATION(clientId)); @@ -56,7 +60,7 @@ export function useOAuthPublicApp(clientId: string | null): PublicAppState { } setState({status: 'ready', data: resp.body, error: null}); } catch (err) { - if (cancelled) return; + if (cancelled || isAccountTransitionAbortError(err)) return; if (err instanceof HttpError && err.status === 401) { logger.warn('OAuth public app fetch returned 401', err); setState({status: 'session_expired', data: null, error: err}); @@ -69,6 +73,6 @@ export function useOAuthPublicApp(clientId: string | null): PublicAppState { return () => { cancelled = true; }; - }, [clientId, currentAccountKey, currentUserId]); + }, [clientId, currentAccountKey, currentUserId, accountWorkAdmitted]); return state; } diff --git a/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/state/useAuthorizeFlow.ts b/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/state/useAuthorizeFlow.ts index da34d6995..35c0ce614 100644 --- a/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/state/useAuthorizeFlow.ts +++ b/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/state/useAuthorizeFlow.ts @@ -185,7 +185,11 @@ export function useAuthorizeFlow(options: UseAuthorizeFlowOptions = {}): Authori const initialMissingClientId = !params; const publicAppState = useOAuthPublicApp(params?.clientId ?? null); const scopeSelection = useScopeSelection(scopes); - const destinations = useBotInviteDestinations(hasBotScope, params?.permissions ?? null); + const destinations = useBotInviteDestinations( + hasBotScope, + params?.permissions ?? null, + phase.kind === 'review' && phase.step === 'community', + ); const selectedDestinationTarget = parseBotInviteDestinationKey(selectedDestinationKey); const selectedGuildId = selectedDestinationTarget?.kind === 'guild' ? selectedDestinationTarget.id : null; const permissionSelection = usePermissionSelection( @@ -280,18 +284,15 @@ export function useAuthorizeFlow(options: UseAuthorizeFlowOptions = {}): Authori }); }, [phase.kind]); const currentAccountKey = Accounts.currentAccountKey; - const destinationInitRef = useRef(false); useEffect(() => { - destinationInitRef.current = false; setSelectedDestinationKey(null); }, [currentAccountKey]); useEffect(() => { if (!hasBotScope) { - destinationInitRef.current = false; setSelectedDestinationKey(null); return; } - if (destinationInitRef.current) return; + if (destinations.options.some((option) => option.value === selectedDestinationKey)) return; const requestedDestinationKey = params?.channelId ? createBotInviteDestinationKey('group_dm', params.channelId) : params?.guildId @@ -304,23 +305,23 @@ export function useAuthorizeFlow(options: UseAuthorizeFlowOptions = {}): Authori ? requestedDestinationKey : null, ); - destinationInitRef.current = true; return; } if (destinations.status !== 'ready') return; const preferredDestinationKey = preferredDestinationKeyRef.current; if (preferredDestinationKey && destinations.options.some((option) => option.value === preferredDestinationKey)) { setSelectedDestinationKey(preferredDestinationKey); - destinationInitRef.current = true; return; } - if (destinations.options.length > 0) { - setSelectedDestinationKey(destinations.options[0].value); - destinationInitRef.current = true; - return; - } - destinationInitRef.current = true; - }, [hasBotScope, params?.guildId, params?.channelId, destinations.options, destinations.status]); + setSelectedDestinationKey(destinations.options[0]?.value ?? null); + }, [ + hasBotScope, + params?.guildId, + params?.channelId, + destinations.options, + destinations.status, + selectedDestinationKey, + ]); const selectedDestination = useMemo( () => destinations.options.find((option) => option.value === selectedDestinationKey) ?? null, [destinations.options, selectedDestinationKey], diff --git a/fluxer_app/src/features/auth/flow/AuthLoginLayout.tsx b/fluxer_app/src/features/auth/flow/AuthLoginLayout.tsx index 6d4e97925..14c12f384 100644 --- a/fluxer_app/src/features/auth/flow/AuthLoginLayout.tsx +++ b/fluxer_app/src/features/auth/flow/AuthLoginLayout.tsx @@ -121,7 +121,7 @@ export interface AuthLoginLayoutProps { readonly registerLink: ReactElement>; readonly onLoginComplete?: ((payload: LoginSuccessPayload) => Promise | void) | null; readonly onBackActionChange?: ((action: (() => void) | null) => void) | null; - readonly completeLoginRedirectPath?: string | null; + readonly completeLoginRedirectPath: string | null; readonly forceCredentials?: boolean; readonly startWithAddAccount?: boolean; readonly runtimeTarget: AuthRuntimeTarget; @@ -343,15 +343,6 @@ function resolveMethodSsoAction({ return {isStarting, label, onStart}; } -function resolveCompleteLoginOptions( - completeLoginRedirectPath: string | null, -): AuthenticationCommands.CompleteLoginOptions { - if (completeLoginRedirectPath == null) { - return {}; - } - return {redirectPath: completeLoginRedirectPath}; -} - function resolveAuthBackAction({ accountListBackAction, authLoginStep, @@ -400,7 +391,7 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({ registerLink, onLoginComplete = null, onBackActionChange = null, - completeLoginRedirectPath = null, + completeLoginRedirectPath, forceCredentials = false, startWithAddAccount = false, runtimeTarget, @@ -509,7 +500,7 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({ } await AuthenticationCommands.completeLogin( {...payload, runtimeSnapshot: requireAuthInstanceSnapshot(resolvedAuthRuntimeSnapshot)}, - resolveCompleteLoginOptions(completeLoginRedirectPath), + {redirectPath: completeLoginRedirectPath}, ); if (onLoginComplete != null) { await onLoginComplete(payload); @@ -572,7 +563,7 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({ ); const handleStoredAccountLogin = useCallback( async (payload: StoredAccountLoginPayload) => { - await AuthenticationCommands.completeLogin(payload, resolveCompleteLoginOptions(completeLoginRedirectPath)); + await AuthenticationCommands.completeLogin(payload, {redirectPath: completeLoginRedirectPath}); if (onLoginComplete != null) { await onLoginComplete(payload); } diff --git a/fluxer_app/src/features/auth/flow/ResourceAuthPages.tsx b/fluxer_app/src/features/auth/flow/ResourceAuthPages.tsx index ddb44e781..0c7c2b4de 100644 --- a/fluxer_app/src/features/auth/flow/ResourceAuthPages.tsx +++ b/fluxer_app/src/features/auth/flow/ResourceAuthPages.tsx @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {Routes} from '@app/app/Routes'; import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore'; import {usesUsernameSignIn} from '@app/features/app/utils/AccountIdentityFeatures'; import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands'; @@ -141,7 +142,7 @@ const ResourceAuthContent = observer(function ResourceAuthContent({ showTitle={false} title={null} onBackActionChange={null} - completeLoginRedirectPath={null} + completeLoginRedirectPath={Routes.ME} forceCredentials={false} startWithAddAccount={false} runtimeTarget={runtimeTarget} diff --git a/fluxer_app/src/features/invite/components/pages/InviteLoginPage.tsx b/fluxer_app/src/features/invite/components/pages/InviteLoginPage.tsx index 6615e2bad..9233869b3 100644 --- a/fluxer_app/src/features/invite/components/pages/InviteLoginPage.tsx +++ b/fluxer_app/src/features/invite/components/pages/InviteLoginPage.tsx @@ -63,7 +63,7 @@ const InviteLoginPage = observer(function InviteLoginPage({code, invite}: Invite title={null} onLoginComplete={runtimeTarget.reset} onBackActionChange={null} - completeLoginRedirectPath={null} + completeLoginRedirectPath={Routes.ME} forceCredentials={false} startWithAddAccount={false} runtimeTarget={runtimeTarget}