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; return response.body;
} }
export interface CompleteLoginOptions { interface CompleteLoginOptions {
redirectPath?: string | null; redirectPath?: string | null;
} }
@@ -64,7 +64,7 @@ const LoginPage = observer(function LoginPage({onBackActionChange}: LoginPagePro
title={null} title={null}
onLoginComplete={runtimeTarget.reset} onLoginComplete={runtimeTarget.reset}
onBackActionChange={onBackActionChange} onBackActionChange={onBackActionChange}
completeLoginRedirectPath={null} completeLoginRedirectPath={Routes.ME}
forceCredentials={false} forceCredentials={false}
startWithAddAccount={false} startWithAddAccount={false}
runtimeTarget={runtimeTarget} runtimeTarget={runtimeTarget}
@@ -150,7 +150,7 @@ export const OAuthCommunityStep: React.FC<OAuthCommunityStepProps> = ({
{destinationsError} {destinationsError}
</div> </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"> <div className={styles.emptyState} data-flx="auth.o-auth-authorize-page.community-step.empty-state">
{guildSelectorEmptyState} {guildSelectorEmptyState}
</div> </div>
@@ -10,6 +10,8 @@ import {
} from '@app/features/auth/components/pages/oauth_authorize_page/OAuthAuthorizePageShared'; } from '@app/features/auth/components/pages/oauth_authorize_page/OAuthAuthorizePageShared';
import Accounts from '@app/features/auth/state/Accounts'; import Accounts from '@app/features/auth/state/Accounts';
import {formatList} from '@app/features/i18n/utils/IntlCache'; 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 {http} from '@app/features/platform/transport/RestTransport';
import {failureMessage} from '@app/features/platform/utils/ResponseInspection'; import {failureMessage} from '@app/features/platform/utils/ResponseInspection';
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils'; import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
@@ -106,9 +108,14 @@ function getGroupDmLabel(channel: ChannelResponse, unknownGroupDmLabel: string,
return unknownGroupDmLabel; 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 {i18n} = useLingui();
const currentAccountKey = Accounts.currentAccountKey; const currentAccountKey = Accounts.currentAccountKey;
const accountWorkAdmitted = !AccountScopedWork.isSuspended;
const requestedPermissions = useMemo(() => parseRequestedBotPermissions(rawPermissions, false), [rawPermissions]); const requestedPermissions = useMemo(() => parseRequestedBotPermissions(rawPermissions, false), [rawPermissions]);
const i18nRef = useRef(i18n); const i18nRef = useRef(i18n);
i18nRef.current = i18n; i18nRef.current = i18n;
@@ -116,17 +123,32 @@ export function useBotInviteDestinations(enabled: boolean, rawPermissions: strin
const [rawGuilds, setRawGuilds] = useState<ReadonlyArray<GuildSummary>>([]); const [rawGuilds, setRawGuilds] = useState<ReadonlyArray<GuildSummary>>([]);
const [rawChannels, setRawChannels] = useState<ReadonlyArray<ChannelResponse>>([]); const [rawChannels, setRawChannels] = useState<ReadonlyArray<ChannelResponse>>([]);
const [error, setError] = useState<string | null>(null); 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(() => { useEffect(() => {
if (!enabled) { if (!enabled) {
loadedAccountKeyRef.current = null;
setStatus('idle'); setStatus('idle');
setRawGuilds([]); setRawGuilds([]);
setRawChannels([]); setRawChannels([]);
setError(null); setError(null);
return; return;
} }
if (loadedAccountKeyRef.current !== currentAccountKey) {
setStatus('loading');
setError(null);
}
if (!accountWorkAdmitted) return;
let cancelled = false; let cancelled = false;
setStatus('loading');
setError(null);
(async () => { (async () => {
try { try {
const [guildsResp, channelsResp] = await Promise.all([ const [guildsResp, channelsResp] = await Promise.all([
@@ -136,10 +158,13 @@ export function useBotInviteDestinations(enabled: boolean, rawPermissions: strin
if (cancelled) return; if (cancelled) return;
setRawGuilds(guildsResp.body); setRawGuilds(guildsResp.body);
setRawChannels(channelsResp.body.filter((channel) => channel.type === ChannelTypes.GROUP_DM)); setRawChannels(channelsResp.body.filter((channel) => channel.type === ChannelTypes.GROUP_DM));
loadedAccountKeyRef.current = currentAccountKey;
setError(null);
setStatus('ready'); setStatus('ready');
} catch (err) { } catch (err) {
if (cancelled) return; if (cancelled || isAccountTransitionAbortError(err)) return;
logger.error('Failed to fetch bot invite destinations', err); logger.error('Failed to fetch bot invite destinations', err);
loadedAccountKeyRef.current = null;
setError(failureMessage(err) ?? i18nRef.current._(FAILED_TO_LOAD_INVITE_DESTINATIONS_DESCRIPTOR)); setError(failureMessage(err) ?? i18nRef.current._(FAILED_TO_LOAD_INVITE_DESTINATIONS_DESCRIPTOR));
setRawGuilds([]); setRawGuilds([]);
setRawChannels([]); setRawChannels([]);
@@ -149,7 +174,7 @@ export function useBotInviteDestinations(enabled: boolean, rawPermissions: strin
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [enabled, currentAccountKey]); }, [enabled, currentAccountKey, accountWorkAdmitted, revision]);
const guilds = useMemo<ReadonlyArray<GuildWithPermissions>>( const guilds = useMemo<ReadonlyArray<GuildWithPermissions>>(
() => mapBotGuilds(rawGuilds, requestedPermissions, i18n._(UNKNOWN_COMMUNITY_DESCRIPTOR)), () => mapBotGuilds(rawGuilds, requestedPermissions, i18n._(UNKNOWN_COMMUNITY_DESCRIPTOR)),
[rawGuilds, requestedPermissions, i18n.locale], [rawGuilds, requestedPermissions, i18n.locale],
@@ -8,6 +8,8 @@ import {
} from '@app/features/auth/components/pages/oauth_authorize_page/OAuthAuthorizePageShared'; } from '@app/features/auth/components/pages/oauth_authorize_page/OAuthAuthorizePageShared';
import accountStorage from '@app/features/auth/state/AccountStorage'; import accountStorage from '@app/features/auth/state/AccountStorage';
import Accounts from '@app/features/auth/state/Accounts'; 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 {http} from '@app/features/platform/transport/RestTransport';
import {HttpError} from '@app/features/platform/types/EndpointError'; import {HttpError} from '@app/features/platform/types/EndpointError';
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
@@ -23,6 +25,7 @@ export interface PublicAppState {
export function useOAuthPublicApp(clientId: string | null): PublicAppState { export function useOAuthPublicApp(clientId: string | null): PublicAppState {
const currentUserId = Accounts.currentUserId; const currentUserId = Accounts.currentUserId;
const currentAccountKey = Accounts.currentAccountKey; const currentAccountKey = Accounts.currentAccountKey;
const accountWorkAdmitted = !AccountScopedWork.isSuspended;
const [state, setState] = useState<PublicAppState>(() => ({ const [state, setState] = useState<PublicAppState>(() => ({
status: clientId ? 'loading' : 'idle', status: clientId ? 'loading' : 'idle',
data: null, data: null,
@@ -33,8 +36,9 @@ export function useOAuthPublicApp(clientId: string | null): PublicAppState {
setState({status: 'idle', data: null, error: null}); setState({status: 'idle', data: null, error: null});
return; return;
} }
let cancelled = false;
setState({status: 'loading', data: null, error: null}); setState({status: 'loading', data: null, error: null});
if (!accountWorkAdmitted) return;
let cancelled = false;
(async () => { (async () => {
try { try {
const resp = await http.get<PublicAppData>(Endpoints.OAUTH_PUBLIC_APPLICATION(clientId)); 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}); setState({status: 'ready', data: resp.body, error: null});
} catch (err) { } catch (err) {
if (cancelled) return; if (cancelled || isAccountTransitionAbortError(err)) return;
if (err instanceof HttpError && err.status === 401) { if (err instanceof HttpError && err.status === 401) {
logger.warn('OAuth public app fetch returned 401', err); logger.warn('OAuth public app fetch returned 401', err);
setState({status: 'session_expired', data: null, error: err}); setState({status: 'session_expired', data: null, error: err});
@@ -69,6 +73,6 @@ export function useOAuthPublicApp(clientId: string | null): PublicAppState {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [clientId, currentAccountKey, currentUserId]); }, [clientId, currentAccountKey, currentUserId, accountWorkAdmitted]);
return state; return state;
} }
@@ -185,7 +185,11 @@ export function useAuthorizeFlow(options: UseAuthorizeFlowOptions = {}): Authori
const initialMissingClientId = !params; const initialMissingClientId = !params;
const publicAppState = useOAuthPublicApp(params?.clientId ?? null); const publicAppState = useOAuthPublicApp(params?.clientId ?? null);
const scopeSelection = useScopeSelection(scopes); 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 selectedDestinationTarget = parseBotInviteDestinationKey(selectedDestinationKey);
const selectedGuildId = selectedDestinationTarget?.kind === 'guild' ? selectedDestinationTarget.id : null; const selectedGuildId = selectedDestinationTarget?.kind === 'guild' ? selectedDestinationTarget.id : null;
const permissionSelection = usePermissionSelection( const permissionSelection = usePermissionSelection(
@@ -280,18 +284,15 @@ export function useAuthorizeFlow(options: UseAuthorizeFlowOptions = {}): Authori
}); });
}, [phase.kind]); }, [phase.kind]);
const currentAccountKey = Accounts.currentAccountKey; const currentAccountKey = Accounts.currentAccountKey;
const destinationInitRef = useRef(false);
useEffect(() => { useEffect(() => {
destinationInitRef.current = false;
setSelectedDestinationKey(null); setSelectedDestinationKey(null);
}, [currentAccountKey]); }, [currentAccountKey]);
useEffect(() => { useEffect(() => {
if (!hasBotScope) { if (!hasBotScope) {
destinationInitRef.current = false;
setSelectedDestinationKey(null); setSelectedDestinationKey(null);
return; return;
} }
if (destinationInitRef.current) return; if (destinations.options.some((option) => option.value === selectedDestinationKey)) return;
const requestedDestinationKey = params?.channelId const requestedDestinationKey = params?.channelId
? createBotInviteDestinationKey('group_dm', params.channelId) ? createBotInviteDestinationKey('group_dm', params.channelId)
: params?.guildId : params?.guildId
@@ -304,23 +305,23 @@ export function useAuthorizeFlow(options: UseAuthorizeFlowOptions = {}): Authori
? requestedDestinationKey ? requestedDestinationKey
: null, : null,
); );
destinationInitRef.current = true;
return; return;
} }
if (destinations.status !== 'ready') return; if (destinations.status !== 'ready') return;
const preferredDestinationKey = preferredDestinationKeyRef.current; const preferredDestinationKey = preferredDestinationKeyRef.current;
if (preferredDestinationKey && destinations.options.some((option) => option.value === preferredDestinationKey)) { if (preferredDestinationKey && destinations.options.some((option) => option.value === preferredDestinationKey)) {
setSelectedDestinationKey(preferredDestinationKey); setSelectedDestinationKey(preferredDestinationKey);
destinationInitRef.current = true;
return; return;
} }
if (destinations.options.length > 0) { setSelectedDestinationKey(destinations.options[0]?.value ?? null);
setSelectedDestinationKey(destinations.options[0].value); }, [
destinationInitRef.current = true; hasBotScope,
return; params?.guildId,
} params?.channelId,
destinationInitRef.current = true; destinations.options,
}, [hasBotScope, params?.guildId, params?.channelId, destinations.options, destinations.status]); destinations.status,
selectedDestinationKey,
]);
const selectedDestination = useMemo( const selectedDestination = useMemo(
() => destinations.options.find((option) => option.value === selectedDestinationKey) ?? null, () => destinations.options.find((option) => option.value === selectedDestinationKey) ?? null,
[destinations.options, selectedDestinationKey], [destinations.options, selectedDestinationKey],
@@ -121,7 +121,7 @@ export interface AuthLoginLayoutProps {
readonly registerLink: ReactElement<Record<string, unknown>>; readonly registerLink: ReactElement<Record<string, unknown>>;
readonly onLoginComplete?: ((payload: LoginSuccessPayload) => Promise<void> | void) | null; readonly onLoginComplete?: ((payload: LoginSuccessPayload) => Promise<void> | void) | null;
readonly onBackActionChange?: ((action: (() => void) | null) => void) | null; readonly onBackActionChange?: ((action: (() => void) | null) => void) | null;
readonly completeLoginRedirectPath?: string | null; readonly completeLoginRedirectPath: string | null;
readonly forceCredentials?: boolean; readonly forceCredentials?: boolean;
readonly startWithAddAccount?: boolean; readonly startWithAddAccount?: boolean;
readonly runtimeTarget: AuthRuntimeTarget; readonly runtimeTarget: AuthRuntimeTarget;
@@ -343,15 +343,6 @@ function resolveMethodSsoAction({
return {isStarting, label, onStart}; return {isStarting, label, onStart};
} }
function resolveCompleteLoginOptions(
completeLoginRedirectPath: string | null,
): AuthenticationCommands.CompleteLoginOptions {
if (completeLoginRedirectPath == null) {
return {};
}
return {redirectPath: completeLoginRedirectPath};
}
function resolveAuthBackAction({ function resolveAuthBackAction({
accountListBackAction, accountListBackAction,
authLoginStep, authLoginStep,
@@ -400,7 +391,7 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
registerLink, registerLink,
onLoginComplete = null, onLoginComplete = null,
onBackActionChange = null, onBackActionChange = null,
completeLoginRedirectPath = null, completeLoginRedirectPath,
forceCredentials = false, forceCredentials = false,
startWithAddAccount = false, startWithAddAccount = false,
runtimeTarget, runtimeTarget,
@@ -509,7 +500,7 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
} }
await AuthenticationCommands.completeLogin( await AuthenticationCommands.completeLogin(
{...payload, runtimeSnapshot: requireAuthInstanceSnapshot(resolvedAuthRuntimeSnapshot)}, {...payload, runtimeSnapshot: requireAuthInstanceSnapshot(resolvedAuthRuntimeSnapshot)},
resolveCompleteLoginOptions(completeLoginRedirectPath), {redirectPath: completeLoginRedirectPath},
); );
if (onLoginComplete != null) { if (onLoginComplete != null) {
await onLoginComplete(payload); await onLoginComplete(payload);
@@ -572,7 +563,7 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
); );
const handleStoredAccountLogin = useCallback( const handleStoredAccountLogin = useCallback(
async (payload: StoredAccountLoginPayload) => { async (payload: StoredAccountLoginPayload) => {
await AuthenticationCommands.completeLogin(payload, resolveCompleteLoginOptions(completeLoginRedirectPath)); await AuthenticationCommands.completeLogin(payload, {redirectPath: completeLoginRedirectPath});
if (onLoginComplete != null) { if (onLoginComplete != null) {
await onLoginComplete(payload); await onLoginComplete(payload);
} }
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import {Routes} from '@app/app/Routes';
import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore'; import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore';
import {usesUsernameSignIn} from '@app/features/app/utils/AccountIdentityFeatures'; import {usesUsernameSignIn} from '@app/features/app/utils/AccountIdentityFeatures';
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands'; import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
@@ -141,7 +142,7 @@ const ResourceAuthContent = observer(function ResourceAuthContent<T>({
showTitle={false} showTitle={false}
title={null} title={null}
onBackActionChange={null} onBackActionChange={null}
completeLoginRedirectPath={null} completeLoginRedirectPath={Routes.ME}
forceCredentials={false} forceCredentials={false}
startWithAddAccount={false} startWithAddAccount={false}
runtimeTarget={runtimeTarget} runtimeTarget={runtimeTarget}
@@ -63,7 +63,7 @@ const InviteLoginPage = observer(function InviteLoginPage({code, invite}: Invite
title={null} title={null}
onLoginComplete={runtimeTarget.reset} onLoginComplete={runtimeTarget.reset}
onBackActionChange={null} onBackActionChange={null}
completeLoginRedirectPath={null} completeLoginRedirectPath={Routes.ME}
forceCredentials={false} forceCredentials={false}
startWithAddAccount={false} startWithAddAccount={false}
runtimeTarget={runtimeTarget} runtimeTarget={runtimeTarget}