fix(app): refetch oauth bot destinations after account switch (#3460)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-14 15:39:33 +02:00
1 parent c0f825ae2a
commit 3c8fe74660
9 files changed
+62 -40

No files matched your search

@@ -769,7 +769,7 @@ export async function fetchDesktopHandoffInfo({
return response.body;
}
export interface CompleteLoginOptions {
interface CompleteLoginOptions {
redirectPath?: string | null;
}
@@ -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}
@@ -150,7 +150,7 @@ export const OAuthCommunityStep: React.FC<OAuthCommunityStepProps> = ({
{destinationsError}
</div>
)}
{!destinationsLoading && destinationOptions.length === 0 && (
{!destinationsLoading && !destinationsError && destinationOptions.length === 0 && (
<div className={styles.emptyState} data-flx="auth.o-auth-authorize-page.community-step.empty-state">
{guildSelectorEmptyState}
</div>
@@ -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<ReadonlyArray<GuildSummary>>([]);
const [rawChannels, setRawChannels] = useState<ReadonlyArray<ChannelResponse>>([]);
const [error, setError] = useState<string | null>(null);
const [revision, setRevision] = useState(0);
const loadedAccountKeyRef = useRef<string | null>(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<ReadonlyArray<GuildWithPermissions>>(
() => mapBotGuilds(rawGuilds, requestedPermissions, i18n._(UNKNOWN_COMMUNITY_DESCRIPTOR)),
[rawGuilds, requestedPermissions, i18n.locale],
@@ -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<PublicAppState>(() => ({
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<PublicAppData>(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;
}
@@ -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],
@@ -121,7 +121,7 @@ export interface AuthLoginLayoutProps {
readonly registerLink: ReactElement<Record<string, unknown>>;
readonly onLoginComplete?: ((payload: LoginSuccessPayload) => Promise<void> | 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);
}
@@ -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<T>({
showTitle={false}
title={null}
onBackActionChange={null}
completeLoginRedirectPath={null}
completeLoginRedirectPath={Routes.ME}
forceCredentials={false}
startWithAddAccount={false}
runtimeTarget={runtimeTarget}
@@ -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}