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;
+ }
+}