mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat: wire up Connect Discord OAuth on the Rewards page (#3748)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b184a1ba2a
commit
3cf3c5443b
@@ -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 (
|
||||
<>
|
||||
<section className="relative overflow-hidden rounded-[1.25rem] bg-gradient-to-br from-[#004ad0] to-[#2b64f1] p-6 text-white shadow-[0_20px_40px_rgba(25,28,30,0.08)]">
|
||||
@@ -109,24 +142,43 @@ export default function RewardsCommunityTab({
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<button
|
||||
onClick={() => navigate('/connections')}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl bg-white dark:bg-neutral-900 px-4 py-3 text-sm font-semibold text-primary-700 dark:text-primary-300 shadow-lg transition-transform active:scale-[0.98]">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13.828 10.172a4 4 0 0 0-5.656 0l-1 1a4 4 0 0 0 5.656 5.656l.586-.586m-3.242-2.828a4 4 0 0 0 5.656 0l1-1a4 4 0 1 0-5.656-5.656l-.586.586"
|
||||
/>
|
||||
</svg>
|
||||
{t('rewards.community.connectDiscord')}
|
||||
</button>
|
||||
{discordLinked ? (
|
||||
<div
|
||||
data-testid="rewards-discord-connected"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl bg-white/15 px-4 py-3 text-sm font-semibold text-white">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{discordUsername
|
||||
? t('rewards.community.discordConnectedAs').replace('{username}', discordUsername)
|
||||
: t('rewards.community.discordConnected')}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
void handleConnectDiscord();
|
||||
}}
|
||||
disabled={connectState === 'connecting'}
|
||||
data-testid="rewards-connect-discord"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl bg-white dark:bg-neutral-900 px-4 py-3 text-sm font-semibold text-primary-700 dark:text-primary-300 shadow-lg transition-transform active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-70">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13.828 10.172a4 4 0 0 0-5.656 0l-1 1a4 4 0 0 0 5.656 5.656l.586-.586m-3.242-2.828a4 4 0 0 0 5.656 0l1-1a4 4 0 1 0-5.656-5.656l-.586.586"
|
||||
/>
|
||||
</svg>
|
||||
{connectState === 'connecting'
|
||||
? t('rewards.community.connectingDiscord')
|
||||
: t('rewards.community.connectDiscord')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
void openUrl(inviteUrl);
|
||||
@@ -138,6 +190,14 @@ export default function RewardsCommunityTab({
|
||||
{t('rewards.community.joinDiscord')}
|
||||
</button>
|
||||
</div>
|
||||
{connectState === 'error' ? (
|
||||
<p
|
||||
role="alert"
|
||||
data-testid="rewards-connect-discord-error"
|
||||
className="text-xs font-medium text-white/90">
|
||||
{t('rewards.community.connectDiscordError')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="absolute -right-10 -top-10 h-32 w-32 rounded-full bg-white/10 blur-2xl" />
|
||||
<div className="absolute -bottom-10 -left-8 h-24 w-24 rounded-full bg-white/15 blur-xl" />
|
||||
@@ -315,6 +375,16 @@ export default function RewardsCommunityTab({
|
||||
: t('rewards.community.discordStatusUnavailable')}
|
||||
</span>
|
||||
</div>
|
||||
{discordLinked && discordUsername ? (
|
||||
<div className="mt-3 flex items-center justify-between gap-3">
|
||||
<span>{t('rewards.community.discordAccount')}</span>
|
||||
<span
|
||||
data-testid="rewards-discord-username"
|
||||
className="font-semibold text-stone-900 dark:text-neutral-100">
|
||||
{discordUsername}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-3 flex items-center justify-between gap-3">
|
||||
<span>{t('rewards.community.currentStreak')}</span>
|
||||
<span className="font-semibold text-stone-900 dark:text-neutral-100">
|
||||
|
||||
@@ -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(
|
||||
<MemoryRouter>
|
||||
<RewardsCommunityTab error={null} isLoading={false} snapshot={notLinkedSnapshot()} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
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(
|
||||
<MemoryRouter>
|
||||
<RewardsCommunityTab error={null} isLoading={false} snapshot={notLinkedSnapshot()} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
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(
|
||||
<MemoryRouter>
|
||||
<RewardsCommunityTab error={null} isLoading={false} snapshot={notLinkedSnapshot()} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
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(
|
||||
<MemoryRouter>
|
||||
<RewardsCommunityTab error={null} isLoading={false} snapshot={buildSnapshot()} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('rewards-discord-connected')).toHaveTextContent('cooluser');
|
||||
expect(screen.getByTestId('rewards-discord-username')).toHaveTextContent('cooluser');
|
||||
expect(screen.queryByTestId('rewards-connect-discord')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 غير مرتبط',
|
||||
|
||||
@@ -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 লিংক করা হয়নি',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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é',
|
||||
|
||||
@@ -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 लिंक्ड नहीं',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '연결되지 않음',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 не привязан',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
<MemoryRouter>
|
||||
<Rewards />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(rewardsApi.getMyRewards).toHaveBeenCalledTimes(1));
|
||||
|
||||
fireEvent(window, new CustomEvent('oauth:success', { detail: { toolkit: 'discord' } }));
|
||||
|
||||
await waitFor(() => expect(rewardsApi.getMyRewards).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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' ||
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface RewardsSnapshot {
|
||||
discord: {
|
||||
linked: boolean;
|
||||
discordId: string | null;
|
||||
username: string | null;
|
||||
inviteUrl: string | null;
|
||||
membershipStatus: RewardsDiscordMembershipStatus;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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<StoredReturnRoute>;
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user