From 3cf3c5443b1cac0d8d58adc3ff575b101ee8109c Mon Sep 17 00:00:00 2001 From: YellowSnnowmann <167776381+YellowSnnowmann@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:50:00 +0530 Subject: [PATCH] feat: wire up Connect Discord OAuth on the Rewards page (#3748) Co-authored-by: Claude Opus 4.8 --- .../rewards/RewardsCommunityTab.tsx | 110 ++++++++++++++---- .../__tests__/RewardsCommunityTab.test.tsx | 103 +++++++++++++++- app/src/lib/i18n/ar.ts | 5 + app/src/lib/i18n/bn.ts | 5 + app/src/lib/i18n/de.ts | 6 + app/src/lib/i18n/en.ts | 5 + app/src/lib/i18n/es.ts | 6 + app/src/lib/i18n/fr.ts | 6 + app/src/lib/i18n/hi.ts | 6 + app/src/lib/i18n/id.ts | 6 + app/src/lib/i18n/it.ts | 5 + app/src/lib/i18n/ko.ts | 5 + app/src/lib/i18n/pl.ts | 6 + app/src/lib/i18n/pt.ts | 6 + app/src/lib/i18n/ru.ts | 6 + app/src/lib/i18n/zh-CN.ts | 5 + app/src/pages/Rewards.tsx | 16 +++ app/src/pages/__tests__/Rewards.test.tsx | 41 +++++++ .../services/api/__tests__/rewardsApi.test.ts | 3 + app/src/services/api/rewardsApi.ts | 1 + app/src/types/rewards.ts | 1 + .../utils/__tests__/oauthReturnRoute.test.ts | 79 +++++++++++++ app/src/utils/desktopDeepLinkListener.ts | 7 +- app/src/utils/oauthReturnRoute.ts | 55 +++++++++ 24 files changed, 470 insertions(+), 24 deletions(-) create mode 100644 app/src/utils/__tests__/oauthReturnRoute.test.ts create mode 100644 app/src/utils/oauthReturnRoute.ts diff --git a/app/src/components/rewards/RewardsCommunityTab.tsx b/app/src/components/rewards/RewardsCommunityTab.tsx index c12dfdcff..961128f1e 100644 --- a/app/src/components/rewards/RewardsCommunityTab.tsx +++ b/app/src/components/rewards/RewardsCommunityTab.tsx @@ -1,10 +1,15 @@ -import { useNavigate } from 'react-router-dom'; +import createDebug from 'debug'; +import { useCallback, useState } from 'react'; import { useT } from '../../lib/i18n/I18nContext'; +import { callCoreRpc } from '../../services/coreRpcClient'; import type { RewardsAchievement, RewardsSnapshot } from '../../types/rewards'; import { DISCORD_INVITE_URL } from '../../utils/links'; +import { setOAuthReturnRoute } from '../../utils/oauthReturnRoute'; import { openUrl } from '../../utils/openUrl'; +const log = createDebug('rewards:discord'); + // discordMembershipLabel is now inlined into JSX to access t() function formatNumber(value: number): string { @@ -85,7 +90,7 @@ export default function RewardsCommunityTab({ snapshot, }: RewardsCommunityTabProps) { const { t } = useT(); - const navigate = useNavigate(); + const [connectState, setConnectState] = useState<'idle' | 'connecting' | 'error'>('idle'); const rewardRoles: RewardsAchievement[] = snapshot?.achievements ?? []; const unlocked = snapshot?.summary.unlockedCount ?? rewardRoles.filter(role => role.unlocked).length; @@ -96,6 +101,34 @@ export default function RewardsCommunityTab({ rewardRoles.length > 0 ? rewardRoles.slice(0, 8) : new Array(4).fill(null); const ringCircumference = 2 * Math.PI * 24; const ringOffset = ringCircumference - (progressPercent / 100) * ringCircumference; + const discordLinked = snapshot?.discord.linked ?? false; + const discordUsername = snapshot?.discord.username ?? null; + + const handleConnectDiscord = useCallback(async () => { + log('connect discord requested'); + setConnectState('connecting'); + try { + const response = await callCoreRpc<{ result: { oauthUrl?: string } }>({ + method: 'openhuman.auth.oauth_connect', + params: { provider: 'discord' }, + }); + const oauthUrl = response.result?.oauthUrl; + if (!oauthUrl) { + throw new Error('missing oauthUrl in oauth_connect response'); + } + log('opening discord oauth consent url'); + await openUrl(oauthUrl); + // Persist the return route only after the consent URL actually launched, so a failed + // initiation never leaves a stale route that could misroute a later OAuth success. + setOAuthReturnRoute('/rewards'); + // Reset so the button is usable again if the user cancels; once the snapshot + // refetches with discord.linked the connected state takes over. + setConnectState('idle'); + } catch (err) { + log('connect discord failed error=%s', err instanceof Error ? err.message : String(err)); + setConnectState('error'); + } + }, []); return ( <>
@@ -109,24 +142,43 @@ export default function RewardsCommunityTab({

- + {discordLinked ? ( +
+ + {discordUsername + ? t('rewards.community.discordConnectedAs').replace('{username}', discordUsername) + : t('rewards.community.discordConnected')} +
+ ) : ( + + )}
+ {connectState === 'error' ? ( +

+ {t('rewards.community.connectDiscordError')} +

+ ) : null}
@@ -315,6 +375,16 @@ export default function RewardsCommunityTab({ : t('rewards.community.discordStatusUnavailable')}
+ {discordLinked && discordUsername ? ( +
+ {t('rewards.community.discordAccount')} + + {discordUsername} + +
+ ) : null}
{t('rewards.community.currentStreak')} diff --git a/app/src/components/rewards/__tests__/RewardsCommunityTab.test.tsx b/app/src/components/rewards/__tests__/RewardsCommunityTab.test.tsx index 994af8ea3..f50d9eaf1 100644 --- a/app/src/components/rewards/__tests__/RewardsCommunityTab.test.tsx +++ b/app/src/components/rewards/__tests__/RewardsCommunityTab.test.tsx @@ -3,19 +3,28 @@ * branch (line 248) added by PR #2095's dark-mode pass so the diff * coverage gate has the touched line covered. */ -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { RewardsSnapshot } from '../../../types/rewards'; -vi.mock('../../../utils/openUrl', () => ({ openUrl: vi.fn() })); +const { openUrl, callCoreRpc, setOAuthReturnRoute } = vi.hoisted(() => ({ + openUrl: vi.fn(), + callCoreRpc: vi.fn(), + setOAuthReturnRoute: vi.fn(), +})); + +vi.mock('../../../utils/openUrl', () => ({ openUrl })); +vi.mock('../../../services/coreRpcClient', () => ({ callCoreRpc })); +vi.mock('../../../utils/oauthReturnRoute', () => ({ setOAuthReturnRoute })); function buildSnapshot(): RewardsSnapshot { return { discord: { linked: true, discordId: 'discord-1', + username: 'cooluser', inviteUrl: 'https://discord.gg/example', membershipStatus: 'member', }, @@ -77,3 +86,91 @@ describe('RewardsCommunityTab — role card branches', () => { expect(screen.getByText('Veteran')).toBeInTheDocument(); }); }); + +describe('RewardsCommunityTab — Connect Discord', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + function notLinkedSnapshot(): RewardsSnapshot { + const snapshot = buildSnapshot(); + return { + ...snapshot, + discord: { + linked: false, + discordId: null, + username: null, + inviteUrl: 'https://discord.gg/example', + membershipStatus: 'not_linked', + }, + }; + } + + it('starts the OAuth flow and opens the consent URL on connect', async () => { + callCoreRpc.mockResolvedValueOnce({ result: { oauthUrl: 'https://discord.com/oauth' } }); + const { default: RewardsCommunityTab } = await import('../RewardsCommunityTab'); + render( + + + + ); + + fireEvent.click(screen.getByTestId('rewards-connect-discord')); + + await waitFor(() => expect(openUrl).toHaveBeenCalledWith('https://discord.com/oauth')); + // Return route is persisted only after the consent URL launches. + await waitFor(() => expect(setOAuthReturnRoute).toHaveBeenCalledWith('/rewards')); + expect(callCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.auth.oauth_connect', + params: { provider: 'discord' }, + }); + }); + + it('surfaces an error when the RPC returns no oauthUrl', async () => { + callCoreRpc.mockResolvedValueOnce({ result: {} }); + const { default: RewardsCommunityTab } = await import('../RewardsCommunityTab'); + render( + + + + ); + + fireEvent.click(screen.getByTestId('rewards-connect-discord')); + + await waitFor(() => + expect(screen.getByTestId('rewards-connect-discord-error')).toBeInTheDocument() + ); + expect(openUrl).not.toHaveBeenCalled(); + }); + + it('surfaces an error when the connect RPC rejects', async () => { + callCoreRpc.mockRejectedValueOnce(new Error('rpc down')); + const { default: RewardsCommunityTab } = await import('../RewardsCommunityTab'); + render( + + + + ); + + fireEvent.click(screen.getByTestId('rewards-connect-discord')); + + await waitFor(() => + expect(screen.getByTestId('rewards-connect-discord-error')).toBeInTheDocument() + ); + // A failed initiation must not persist any return route (it's only set after launch). + expect(setOAuthReturnRoute).not.toHaveBeenCalled(); + }); + + it('renders the connected username pill and footer when linked', async () => { + const { default: RewardsCommunityTab } = await import('../RewardsCommunityTab'); + render( + + + + ); + + expect(screen.getByTestId('rewards-discord-connected')).toHaveTextContent('cooluser'); + expect(screen.getByTestId('rewards-discord-username')).toHaveTextContent('cooluser'); + expect(screen.queryByTestId('rewards-connect-discord')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 2a01ba146..b89e553db 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -3145,8 +3145,13 @@ const messages: TranslationMap = { 'privacy.whatLeaves.link.label': 'ما الذي يغادر جهازي؟', 'rewards.community.achievementsUnlocked': 'تم فتح {unlocked} من {total} إنجازات', 'rewards.community.connectDiscord': 'ربط Discord', + 'rewards.community.connectDiscordError': 'تعذّر بدء الاتصال بـ Discord. يرجى المحاولة مرة أخرى.', + 'rewards.community.connectingDiscord': 'جارٍ الاتصال…', 'rewards.community.cumulativeTokens': 'الرموز التراكمية', 'rewards.community.currentStreak': 'السلسلة الحالية', + 'rewards.community.discordAccount': 'حساب Discord', + 'rewards.community.discordConnected': 'تم الاتصال بـ Discord', + 'rewards.community.discordConnectedAs': 'متصل باسم {username}', 'rewards.community.discordLinkedNotInGuild': 'Discord مرتبط لكن ليس في السيرفر', 'rewards.community.discordMember': 'انضممت إلى الخادم', 'rewards.community.discordNotLinked': 'Discord غير مرتبط', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index c591cffc7..f8e165fb6 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -3215,8 +3215,13 @@ const messages: TranslationMap = { 'privacy.whatLeaves.link.label': 'আমার কম্পিউটার থেকে কী বের হয়?', 'rewards.community.achievementsUnlocked': '{total}টির মধ্যে {unlocked}টি অর্জন আনলক হয়েছে', 'rewards.community.connectDiscord': 'Discord সংযুক্ত করুন', + 'rewards.community.connectDiscordError': 'Discord সংযোগ শুরু করা যায়নি। আবার চেষ্টা করুন।', + 'rewards.community.connectingDiscord': 'সংযুক্ত হচ্ছে…', 'rewards.community.cumulativeTokens': 'সঞ্চিত টোকেন', 'rewards.community.currentStreak': 'বর্তমান স্ট্রিক', + 'rewards.community.discordAccount': 'Discord অ্যাকাউন্ট', + 'rewards.community.discordConnected': 'Discord সংযুক্ত', + 'rewards.community.discordConnectedAs': '{username} হিসেবে সংযুক্ত', 'rewards.community.discordLinkedNotInGuild': 'Discord লিংক কিন্তু গিল্ডে নেই', 'rewards.community.discordMember': 'সার্ভারে যোগ দিয়েছেন', 'rewards.community.discordNotLinked': 'Discord লিংক করা হয়নি', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 54679c415..8526ec023 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -3295,8 +3295,14 @@ const messages: TranslationMap = { 'privacy.whatLeaves.link.label': 'Was verlässt meinen Computer?', 'rewards.community.achievementsUnlocked': '{unlocked} von {total} Erfolgen freigeschaltet', 'rewards.community.connectDiscord': 'Verbinde Discord', + 'rewards.community.connectDiscordError': + 'Discord-Verbindung konnte nicht gestartet werden. Bitte versuche es erneut.', + 'rewards.community.connectingDiscord': 'Verbinden…', 'rewards.community.cumulativeTokens': 'Kumulierte Token', 'rewards.community.currentStreak': 'Aktuelle Serie', + 'rewards.community.discordAccount': 'Discord-Konto', + 'rewards.community.discordConnected': 'Discord verbunden', + 'rewards.community.discordConnectedAs': 'Verbunden als {username}', 'rewards.community.discordLinkedNotInGuild': 'Verlinkt, aber nicht auf dem Server', 'rewards.community.discordMember': 'Dem Server beigetreten', 'rewards.community.discordNotLinked': 'Nicht verlinkt', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index ebf90ccc2..52baba9f5 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -3807,8 +3807,13 @@ const en: TranslationMap = { 'privacy.whatLeaves.link.label': 'What leaves my computer?', 'rewards.community.achievementsUnlocked': '{unlocked} of {total} achievements unlocked', 'rewards.community.connectDiscord': 'Connect discord', + 'rewards.community.connectDiscordError': 'Could not start Discord connection. Please try again.', + 'rewards.community.connectingDiscord': 'Connecting…', 'rewards.community.cumulativeTokens': 'Cumulative tokens', 'rewards.community.currentStreak': 'Current streak', + 'rewards.community.discordAccount': 'Discord account', + 'rewards.community.discordConnected': 'Discord connected', + 'rewards.community.discordConnectedAs': 'Connected as {username}', 'rewards.community.discordLinkedNotInGuild': 'Discord linked — not yet a server member', 'rewards.community.discordMember': 'Joined the server', 'rewards.community.discordNotLinked': 'Discord not connected', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index f62a4013f..ff6c56c38 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -3275,8 +3275,14 @@ const messages: TranslationMap = { 'privacy.whatLeaves.link.label': '¿Qué sale de mi ordenador?', 'rewards.community.achievementsUnlocked': '{unlocked} de {total} logros desbloqueados', 'rewards.community.connectDiscord': 'Conectar Discord', + 'rewards.community.connectDiscordError': + 'No se pudo iniciar la conexión con Discord. Inténtalo de nuevo.', + 'rewards.community.connectingDiscord': 'Conectando…', 'rewards.community.cumulativeTokens': 'Tokens acumulados', 'rewards.community.currentStreak': 'Racha actual', + 'rewards.community.discordAccount': 'Cuenta de Discord', + 'rewards.community.discordConnected': 'Discord conectado', + 'rewards.community.discordConnectedAs': 'Conectado como {username}', 'rewards.community.discordLinkedNotInGuild': 'Discord vinculado pero no en el servidor', 'rewards.community.discordMember': 'Te uniste al servidor', 'rewards.community.discordNotLinked': 'Discord no vinculado', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index c71f4a2cf..9684c92fc 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -3287,8 +3287,14 @@ const messages: TranslationMap = { 'privacy.whatLeaves.link.label': "Qu'est-ce qui quitte mon ordinateur ?", 'rewards.community.achievementsUnlocked': '{unlocked} sur {total} succès débloqués', 'rewards.community.connectDiscord': 'Connecter Discord', + 'rewards.community.connectDiscordError': + 'Impossible de démarrer la connexion à Discord. Veuillez réessayer.', + 'rewards.community.connectingDiscord': 'Connexion…', 'rewards.community.cumulativeTokens': 'Tokens cumulés', 'rewards.community.currentStreak': 'Série actuelle', + 'rewards.community.discordAccount': 'Compte Discord', + 'rewards.community.discordConnected': 'Discord connecté', + 'rewards.community.discordConnectedAs': 'Connecté en tant que {username}', 'rewards.community.discordLinkedNotInGuild': 'Discord lié mais pas dans la guilde', 'rewards.community.discordMember': 'A rejoint le serveur', 'rewards.community.discordNotLinked': 'Discord non lié', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 8a76c26bd..d55ef67df 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -3217,8 +3217,14 @@ const messages: TranslationMap = { 'privacy.whatLeaves.link.label': 'मेरे कंप्यूटर से क्या जाता है?', 'rewards.community.achievementsUnlocked': '{total} में से {unlocked} उपलब्धियाँ अनलॉक हुईं', 'rewards.community.connectDiscord': 'Discord कनेक्ट करें', + 'rewards.community.connectDiscordError': + 'Discord कनेक्शन शुरू नहीं हो सका। कृपया फिर से प्रयास करें।', + 'rewards.community.connectingDiscord': 'कनेक्ट हो रहा है…', 'rewards.community.cumulativeTokens': 'कुल टोकन', 'rewards.community.currentStreak': 'मौजूदा स्ट्रीक', + 'rewards.community.discordAccount': 'Discord खाता', + 'rewards.community.discordConnected': 'Discord कनेक्ट हो गया', + 'rewards.community.discordConnectedAs': '{username} के रूप में कनेक्टेड', 'rewards.community.discordLinkedNotInGuild': 'Discord लिंक्ड, Guild में नहीं', 'rewards.community.discordMember': 'सर्वर में शामिल हुए', 'rewards.community.discordNotLinked': 'Discord लिंक्ड नहीं', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 0feda74b3..9f69595d6 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -3222,8 +3222,14 @@ const messages: TranslationMap = { 'privacy.whatLeaves.link.label': 'Apa yang keluar dari komputer saya?', 'rewards.community.achievementsUnlocked': '{unlocked} dari {total} pencapaian terbuka', 'rewards.community.connectDiscord': 'Hubungkan Discord', + 'rewards.community.connectDiscordError': + 'Tidak dapat memulai koneksi Discord. Silakan coba lagi.', + 'rewards.community.connectingDiscord': 'Menghubungkan…', 'rewards.community.cumulativeTokens': 'Token kumulatif', 'rewards.community.currentStreak': 'Streak saat ini', + 'rewards.community.discordAccount': 'Akun Discord', + 'rewards.community.discordConnected': 'Discord terhubung', + 'rewards.community.discordConnectedAs': 'Terhubung sebagai {username}', 'rewards.community.discordLinkedNotInGuild': 'Discord terhubung tidak ada di guild', 'rewards.community.discordMember': 'Bergabung ke server', 'rewards.community.discordNotLinked': 'Discord belum terhubung', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 40a8003f9..a43a6b540 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -3268,8 +3268,13 @@ const messages: TranslationMap = { 'privacy.whatLeaves.link.label': 'Cosa esce dal mio computer?', 'rewards.community.achievementsUnlocked': '{unlocked} di {total} obiettivi sbloccati', 'rewards.community.connectDiscord': 'Connetti Discord', + 'rewards.community.connectDiscordError': 'Impossibile avviare la connessione a Discord. Riprova.', + 'rewards.community.connectingDiscord': 'Connessione…', 'rewards.community.cumulativeTokens': 'Token cumulativi', 'rewards.community.currentStreak': 'Streak attuale', + 'rewards.community.discordAccount': 'Account Discord', + 'rewards.community.discordConnected': 'Discord connesso', + 'rewards.community.discordConnectedAs': 'Connesso come {username}', 'rewards.community.discordLinkedNotInGuild': 'Discord collegato ma non nella gilda', 'rewards.community.discordMember': 'Sei entrato nel server', 'rewards.community.discordNotLinked': 'Discord non collegato', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index d6c9fc032..0a37938fd 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -3187,8 +3187,13 @@ const messages: TranslationMap = { 'privacy.whatLeaves.link.label': '내 컴퓨터를 떠나는 데이터는 무엇인가요?', 'rewards.community.achievementsUnlocked': '{total}개 중 {unlocked}개 업적 잠금 해제됨', 'rewards.community.connectDiscord': 'Discord 연결', + 'rewards.community.connectDiscordError': 'Discord 연결을 시작할 수 없습니다. 다시 시도해 주세요.', + 'rewards.community.connectingDiscord': '연결 중…', 'rewards.community.cumulativeTokens': '누적 토큰', 'rewards.community.currentStreak': '현재 연속 기록', + 'rewards.community.discordAccount': 'Discord 계정', + 'rewards.community.discordConnected': 'Discord 연결됨', + 'rewards.community.discordConnectedAs': '{username}(으)로 연결됨', 'rewards.community.discordLinkedNotInGuild': '연결됨, 하지만 서버 멤버가 아님', 'rewards.community.discordMember': '서버에 참여함', 'rewards.community.discordNotLinked': '연결되지 않음', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 9ce792392..0b6f268ea 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -3263,8 +3263,14 @@ const messages: TranslationMap = { 'privacy.whatLeaves.link.label': 'Co opuszcza mój komputer?', 'rewards.community.achievementsUnlocked': 'Odblokowano {unlocked} z {total} osiągnięć', 'rewards.community.connectDiscord': 'Połącz Discord', + 'rewards.community.connectDiscordError': + 'Nie udało się rozpocząć łączenia z Discord. Spróbuj ponownie.', + 'rewards.community.connectingDiscord': 'Łączenie…', 'rewards.community.cumulativeTokens': 'Łączna liczba tokenów', 'rewards.community.currentStreak': 'Aktualna seria', + 'rewards.community.discordAccount': 'Konto Discord', + 'rewards.community.discordConnected': 'Discord połączony', + 'rewards.community.discordConnectedAs': 'Połączono jako {username}', 'rewards.community.discordLinkedNotInGuild': 'Powiązane, ale nie na serwerze', 'rewards.community.discordMember': 'Dołączono do serwera', 'rewards.community.discordNotLinked': 'Nie powiązane', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index af46fdb84..ca1cd3867 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -3271,8 +3271,14 @@ const messages: TranslationMap = { 'privacy.whatLeaves.link.label': 'O que sai do meu computador?', 'rewards.community.achievementsUnlocked': '{unlocked} de {total} conquistas desbloqueadas', 'rewards.community.connectDiscord': 'Conectar Discord', + 'rewards.community.connectDiscordError': + 'Não foi possível iniciar a conexão com o Discord. Tente novamente.', + 'rewards.community.connectingDiscord': 'Conectando…', 'rewards.community.cumulativeTokens': 'Tokens acumulados', 'rewards.community.currentStreak': 'Sequência atual', + 'rewards.community.discordAccount': 'Conta do Discord', + 'rewards.community.discordConnected': 'Discord conectado', + 'rewards.community.discordConnectedAs': 'Conectado como {username}', 'rewards.community.discordLinkedNotInGuild': 'Discord vinculado mas não no servidor', 'rewards.community.discordMember': 'Entrou no servidor', 'rewards.community.discordNotLinked': 'Discord não vinculado', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 7b4664850..cecc5a68e 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -3243,8 +3243,14 @@ const messages: TranslationMap = { 'privacy.whatLeaves.link.label': 'Что покидает мой компьютер?', 'rewards.community.achievementsUnlocked': 'Открыто достижений: {unlocked} из {total}', 'rewards.community.connectDiscord': 'Подключить Discord', + 'rewards.community.connectDiscordError': + 'Не удалось начать подключение к Discord. Повторите попытку.', + 'rewards.community.connectingDiscord': 'Подключение…', 'rewards.community.cumulativeTokens': 'Токенов всего', 'rewards.community.currentStreak': 'Текущая серия', + 'rewards.community.discordAccount': 'Аккаунт Discord', + 'rewards.community.discordConnected': 'Discord подключён', + 'rewards.community.discordConnectedAs': 'Подключено как {username}', 'rewards.community.discordLinkedNotInGuild': 'Discord привязан, но не в сервере', 'rewards.community.discordMember': 'Присоединился к серверу', 'rewards.community.discordNotLinked': 'Discord не привязан', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index b87b29d2a..3b8d21de1 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -3057,8 +3057,13 @@ const messages: TranslationMap = { 'privacy.whatLeaves.link.label': '哪些数据会离开我的电脑?', 'rewards.community.achievementsUnlocked': '已解锁 {unlocked}/{total} 项成就', 'rewards.community.connectDiscord': '连接 Discord', + 'rewards.community.connectDiscordError': '无法启动 Discord 连接,请重试。', + 'rewards.community.connectingDiscord': '正在连接…', 'rewards.community.cumulativeTokens': '累计令牌', 'rewards.community.currentStreak': '当前连续天数', + 'rewards.community.discordAccount': 'Discord 账户', + 'rewards.community.discordConnected': 'Discord 已连接', + 'rewards.community.discordConnectedAs': '已连接为 {username}', 'rewards.community.discordLinkedNotInGuild': '已关联 Discord 但未加入服务器', 'rewards.community.discordMember': '已加入服务器', 'rewards.community.discordNotLinked': '未关联 Discord', diff --git a/app/src/pages/Rewards.tsx b/app/src/pages/Rewards.tsx index f49f5f398..c2ce51cc5 100644 --- a/app/src/pages/Rewards.tsx +++ b/app/src/pages/Rewards.tsx @@ -74,6 +74,22 @@ const Rewards = () => { }; }, [isLocalSession, loadRewards]); + // After a Discord (or any) OAuth connect completes, the deep-link listener dispatches + // `oauth:success` — refresh the snapshot so the Discord connection / username updates live. + useEffect(() => { + if (isLocalSession) { + return; + } + const handleOAuthSuccess = () => { + log('oauth success event received; refreshing rewards snapshot'); + void loadRewards(); + }; + window.addEventListener('oauth:success', handleOAuthSuccess); + return () => { + window.removeEventListener('oauth:success', handleOAuthSuccess); + }; + }, [isLocalSession, loadRewards]); + const handleTabChange = useCallback((next: RewardsTab) => { log('tab changed next=%s', next); setSelectedTab(next); diff --git a/app/src/pages/__tests__/Rewards.test.tsx b/app/src/pages/__tests__/Rewards.test.tsx index ac9289795..6eb87b4fa 100644 --- a/app/src/pages/__tests__/Rewards.test.tsx +++ b/app/src/pages/__tests__/Rewards.test.tsx @@ -304,4 +304,45 @@ describe('Rewards page', () => { expect(openUrl).toHaveBeenCalledWith('https://discord.gg/openhuman'); }); + + it('refetches the snapshot when an oauth:success event fires', async () => { + rewardsApi.getMyRewards.mockResolvedValue({ + discord: { + linked: false, + discordId: null, + username: null, + inviteUrl: 'https://discord.gg/openhuman', + membershipStatus: 'not_linked', + }, + summary: { + unlockedCount: 0, + totalCount: 0, + assignedDiscordRoleCount: 0, + plan: 'FREE', + hasActiveSubscription: false, + }, + metrics: { + currentStreakDays: 0, + longestStreakDays: 0, + cumulativeTokens: 0, + featuresUsedCount: 0, + trackedFeaturesCount: 0, + lastEvaluatedAt: null, + lastSyncedAt: null, + }, + achievements: [], + }); + + render( + + + + ); + + await waitFor(() => expect(rewardsApi.getMyRewards).toHaveBeenCalledTimes(1)); + + fireEvent(window, new CustomEvent('oauth:success', { detail: { toolkit: 'discord' } })); + + await waitFor(() => expect(rewardsApi.getMyRewards).toHaveBeenCalledTimes(2)); + }); }); diff --git a/app/src/services/api/__tests__/rewardsApi.test.ts b/app/src/services/api/__tests__/rewardsApi.test.ts index ca3599023..b6e5a2562 100644 --- a/app/src/services/api/__tests__/rewardsApi.test.ts +++ b/app/src/services/api/__tests__/rewardsApi.test.ts @@ -10,6 +10,7 @@ describe('normalizeRewardsSnapshot', () => { discord: { linked: true, discordId: 'discord-123', + username: 'cooluser', inviteUrl: 'https://discord.gg/openhuman', membershipStatus: 'member', }, @@ -45,6 +46,7 @@ describe('normalizeRewardsSnapshot', () => { }); expect(snapshot.discord.membershipStatus).toBe('member'); + expect(snapshot.discord.username).toBe('cooluser'); expect(snapshot.summary.plan).toBe('PRO'); expect(snapshot.metrics.currentStreakDays).toBe(7); expect(snapshot.achievements[0].discordRoleStatus).toBe('assigned'); @@ -60,6 +62,7 @@ describe('normalizeRewardsSnapshot', () => { }); expect(snapshot.discord.membershipStatus).toBe('unavailable'); + expect(snapshot.discord.username).toBeNull(); expect(snapshot.summary.plan).toBe('FREE'); expect(snapshot.summary.unlockedCount).toBe(2); expect(snapshot.achievements[0].discordRoleStatus).toBe('unavailable'); diff --git a/app/src/services/api/rewardsApi.ts b/app/src/services/api/rewardsApi.ts index 9d30fd430..2c99c40e2 100644 --- a/app/src/services/api/rewardsApi.ts +++ b/app/src/services/api/rewardsApi.ts @@ -116,6 +116,7 @@ export function normalizeRewardsSnapshot(payload: unknown): RewardsSnapshot { discord: { linked: rawDiscord.linked === true, discordId: asStringOrNull(rawDiscord.discordId), + username: asStringOrNull(rawDiscord.username), inviteUrl: asStringOrNull(rawDiscord.inviteUrl), membershipStatus: rawDiscord.membershipStatus === 'member' || diff --git a/app/src/types/rewards.ts b/app/src/types/rewards.ts index 86c8e7d97..a299dae37 100644 --- a/app/src/types/rewards.ts +++ b/app/src/types/rewards.ts @@ -16,6 +16,7 @@ export interface RewardsSnapshot { discord: { linked: boolean; discordId: string | null; + username: string | null; inviteUrl: string | null; membershipStatus: RewardsDiscordMembershipStatus; }; diff --git a/app/src/utils/__tests__/oauthReturnRoute.test.ts b/app/src/utils/__tests__/oauthReturnRoute.test.ts new file mode 100644 index 000000000..6651ddc62 --- /dev/null +++ b/app/src/utils/__tests__/oauthReturnRoute.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + clearOAuthReturnRoute, + setOAuthReturnRoute, + takeOAuthReturnRoute, +} from '../oauthReturnRoute'; + +const STORAGE_KEY = 'openhuman:oauth:return-route'; + +describe('oauthReturnRoute', () => { + afterEach(() => { + sessionStorage.clear(); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('stores a route and returns it once, clearing it afterwards', () => { + setOAuthReturnRoute('/rewards'); + const stored = JSON.parse(sessionStorage.getItem(STORAGE_KEY) as string); + expect(stored.route).toBe('/rewards'); + + expect(takeOAuthReturnRoute()).toBe('/rewards'); + // Cleared after read → falls back to the default on the next call. + expect(takeOAuthReturnRoute()).toBe('/connections'); + }); + + it('defaults to /connections when nothing is stored', () => { + expect(takeOAuthReturnRoute()).toBe('/connections'); + }); + + it('ignores a stored value that is not an in-app path', () => { + sessionStorage.setItem( + STORAGE_KEY, + JSON.stringify({ route: 'https://evil.example.com', ts: Date.now() }) + ); + expect(takeOAuthReturnRoute()).toBe('/connections'); + }); + + it('ignores corrupt (non-JSON) stored values', () => { + sessionStorage.setItem(STORAGE_KEY, 'not-json'); + expect(takeOAuthReturnRoute()).toBe('/connections'); + }); + + it('ignores a stale route older than the freshness window', () => { + sessionStorage.setItem( + STORAGE_KEY, + JSON.stringify({ route: '/rewards', ts: Date.now() - 6 * 60 * 1000 }) + ); + expect(takeOAuthReturnRoute()).toBe('/connections'); + }); + + it('clearOAuthReturnRoute forgets a stored route', () => { + setOAuthReturnRoute('/rewards'); + clearOAuthReturnRoute(); + expect(takeOAuthReturnRoute()).toBe('/connections'); + }); + + it('falls back to the default when sessionStorage write throws', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('storage unavailable'); + }); + expect(() => setOAuthReturnRoute('/rewards')).not.toThrow(); + }); + + it('falls back to the default when sessionStorage read throws', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('storage unavailable'); + }); + expect(takeOAuthReturnRoute()).toBe('/connections'); + }); + + it('clearOAuthReturnRoute swallows storage errors', () => { + vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => { + throw new Error('storage unavailable'); + }); + expect(() => clearOAuthReturnRoute()).not.toThrow(); + }); +}); diff --git a/app/src/utils/desktopDeepLinkListener.ts b/app/src/utils/desktopDeepLinkListener.ts index 02ace9316..494e242d6 100644 --- a/app/src/utils/desktopDeepLinkListener.ts +++ b/app/src/utils/desktopDeepLinkListener.ts @@ -17,6 +17,7 @@ import { oauthAuthReadinessUserMessage, waitForOAuthAuthReadiness, } from './oauthAppVersionGate'; +import { clearOAuthReturnRoute, takeOAuthReturnRoute } from './oauthReturnRoute'; import { openUrl } from './openUrl'; import { storeSession } from './tauriCommands'; import { isTauri as coreIsTauri } from './tauriCommands/common'; @@ -426,8 +427,12 @@ const handleOAuthDeepLink = async (parsed: URL) => { `[DeepLink] OAuth success for integration=${integrationId}${toolkit ? ` toolkit=${toolkit}` : ''}` ); window.dispatchEvent(new CustomEvent('oauth:success', { detail: { integrationId, toolkit } })); - window.location.hash = '/connections'; + // Return to whichever page started the connect (e.g. the Rewards tab); defaults to /connections. + window.location.hash = takeOAuthReturnRoute(); } else if (path === 'error') { + // The flow failed — drop any remembered return route so it can't leak into a later + // unrelated OAuth success and misroute the user. + clearOAuthReturnRoute(); const provider = sanitizeOAuthDiagnosticValue( parsed.searchParams.get('provider'), 'unknown', diff --git a/app/src/utils/oauthReturnRoute.ts b/app/src/utils/oauthReturnRoute.ts new file mode 100644 index 000000000..a6e027de5 --- /dev/null +++ b/app/src/utils/oauthReturnRoute.ts @@ -0,0 +1,55 @@ +// Remembers which in-app route started an OAuth connect flow so the `openhuman://oauth/success` +// deep link can return the user there instead of always landing on the connections tab. +// +// The value is consumed only on `oauth/success`. A flow that is canceled, fails before +// completion, or returns `oauth/error` never consumes it, so it must not leak into a later +// unrelated OAuth success. Two guards prevent that: callers clear it on their own failure/error +// paths (clearOAuthReturnRoute), and a freshness TTL bounds the window for a silently abandoned +// flow (e.g. the user closes the consent tab and no deep link ever returns). +const STORAGE_KEY = 'openhuman:oauth:return-route'; +const DEFAULT_ROUTE = '/connections'; +const MAX_AGE_MS = 5 * 60 * 1000; + +interface StoredReturnRoute { + route: string; + ts: number; +} + +/** Record the hash route that initiated an OAuth connect (e.g. '/rewards'). */ +export function setOAuthReturnRoute(route: string): void { + try { + const payload: StoredReturnRoute = { route, ts: Date.now() }; + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); + } catch { + // sessionStorage unavailable (private mode / non-browser host) — fall back to the default. + } +} + +/** Forget any remembered route. Call on OAuth failure/cancel so it can't leak into a later flow. */ +export function clearOAuthReturnRoute(): void { + try { + sessionStorage.removeItem(STORAGE_KEY); + } catch { + // ignore — nothing to clear if storage is unavailable. + } +} + +/** + * Read and clear the remembered OAuth return route. Returns the connections tab unless a valid, + * in-app, non-stale route was stored by the flow that just succeeded. + */ +export function takeOAuthReturnRoute(): string { + try { + const raw = sessionStorage.getItem(STORAGE_KEY); + sessionStorage.removeItem(STORAGE_KEY); + if (!raw) return DEFAULT_ROUTE; + const parsed = JSON.parse(raw) as Partial; + const route = typeof parsed.route === 'string' ? parsed.route : null; + const ts = typeof parsed.ts === 'number' ? parsed.ts : 0; + if (!route || !route.startsWith('/')) return DEFAULT_ROUTE; + if (Date.now() - ts > MAX_AGE_MS) return DEFAULT_ROUTE; + return route; + } catch { + return DEFAULT_ROUTE; + } +}