From 1d340d6d76c09b7cde4834f787cd75e081c52773 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Fri, 3 Apr 2026 09:26:28 -0700 Subject: [PATCH 1/5] Switch protocol dashboard connect flow to OAuth 2.0 write scope and add image mirror support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the deprecated write_once OAuth scope with standard write scope for the Connect Audius Profile flow. The old SDK APIs (oauth.init, getCsrfToken, activePopupWindow, OAUTH_URL) no longer exist — rewrite the hook to construct the OAuth URL manually with PKCE, sign wallet signatures with ethers instead of audiusLibs, and exchange auth codes for tokens after the popup completes. Also add mirror-based image loading with 3-second per-URL timeout fallback for profile pictures and trending artwork, replacing raw tags that would stall indefinitely on unresponsive content nodes. Co-Authored-By: Claude Opus 4.6 --- .../src/components/AppBar/AppBar.tsx | 11 +- .../ConnectAudiusProfileCard.tsx | 12 +- .../ConnectAudiusProfileModal.tsx | 5 +- .../components/MirrorImage/MirrorImage.tsx | 60 +++ .../components/TopAlbums/TopAlbums.module.css | 9 +- .../src/components/TopAlbums/TopAlbums.tsx | 20 +- .../TopPlaylists/TopPlaylists.module.css | 9 +- .../components/TopPlaylists/TopPlaylists.tsx | 23 +- .../components/TopTracks/TopTracks.module.css | 9 +- .../src/components/TopTracks/TopTracks.tsx | 20 +- .../TopTracks/TopTracksMobile.module.css | 9 +- .../src/components/UserImage/UserImage.tsx | 35 +- .../src/hooks/useConnectAudiusProfile.ts | 374 +++++++++++++----- .../src/services/Audius/sdk.ts | 1 + .../src/store/cache/music/hooks.ts | 4 + .../src/store/cache/user/hooks.ts | 10 +- packages/protocol-dashboard/src/types.ts | 3 + .../protocol-dashboard/src/utils/imageUrls.ts | 23 ++ .../web/src/pages/oauth-login-page/hooks.ts | 40 ++ 19 files changed, 518 insertions(+), 159 deletions(-) create mode 100644 packages/protocol-dashboard/src/components/MirrorImage/MirrorImage.tsx create mode 100644 packages/protocol-dashboard/src/utils/imageUrls.ts diff --git a/packages/protocol-dashboard/src/components/AppBar/AppBar.tsx b/packages/protocol-dashboard/src/components/AppBar/AppBar.tsx index 4f7ee1ecb64..188d6d346d4 100644 --- a/packages/protocol-dashboard/src/components/AppBar/AppBar.tsx +++ b/packages/protocol-dashboard/src/components/AppBar/AppBar.tsx @@ -195,7 +195,13 @@ const UserAccountSnippet = ({ wallet }: UserAccountSnippetProps) => { ) } -const ConnectAudiusProfileButton = ({ wallet }: { wallet: string }) => { +const ConnectAudiusProfileButton = ({ + wallet, + walletProvider +}: { + wallet: string + walletProvider?: any +}) => { const { isOpen, onClick, onClose } = useModalControls() return ( <> @@ -210,6 +216,7 @@ const ConnectAudiusProfileButton = ({ wallet }: { wallet: string }) => { @@ -298,7 +305,7 @@ const AppBar: React.FC = () => { !wallet || !isLoggedIn || audiusProfileDataStatus === 'pending' ? null : ( - + )}
{ const { isOpen, onClick, onClose } = useModalControls() return ( @@ -32,6 +35,7 @@ const ConnectAudiusProfileButton = ({ /> { const { user: accountUser } = useAccountUser() + const { walletProvider } = useWeb3ModalProvider() const { data: audiusProfileData, status: audiusProfileDataStatus } = useDashboardWalletUser(accountUser?.wallet) @@ -66,7 +71,10 @@ export const ConnectAudiusProfileCard = () => { - + ) diff --git a/packages/protocol-dashboard/src/components/ConnectAudiusProfileModal/ConnectAudiusProfileModal.tsx b/packages/protocol-dashboard/src/components/ConnectAudiusProfileModal/ConnectAudiusProfileModal.tsx index a69194e9a56..1bb31199dce 100644 --- a/packages/protocol-dashboard/src/components/ConnectAudiusProfileModal/ConnectAudiusProfileModal.tsx +++ b/packages/protocol-dashboard/src/components/ConnectAudiusProfileModal/ConnectAudiusProfileModal.tsx @@ -13,7 +13,7 @@ const messages = { connectAudiusProfileDescriptionP1: 'Help other users identify you by connecting your Audius account.', connectAudiusProfileDescriptionP2: - 'Once you’ve linked your Audius account, your Profile Picture and Display Name will be visible to users throughout the protocol dashboard.', + "Once you've linked your Audius account, your Profile Picture and Display Name will be visible to users throughout the protocol dashboard.", connectProfileButton: 'Connect Profile', disconnectAudiusProfileTitle: 'Disconnect Audius Profile', disconnectProfileButton: 'Disconnect Audius Profile', @@ -27,6 +27,7 @@ type ConnectAudiusProfileModalProps = { isOpen: boolean onClose: () => void wallet: string + walletProvider?: any action: 'disconnect' | 'connect' } @@ -34,10 +35,12 @@ export const ConnectAudiusProfileModal = ({ isOpen, onClose, wallet, + walletProvider, action }: ConnectAudiusProfileModalProps) => { const { connect, disconnect, isWaiting } = useConnectAudiusProfile({ wallet, + walletProvider, onSuccess: onClose }) const isConnect = action === 'connect' diff --git a/packages/protocol-dashboard/src/components/MirrorImage/MirrorImage.tsx b/packages/protocol-dashboard/src/components/MirrorImage/MirrorImage.tsx new file mode 100644 index 00000000000..4ee3cb9ebba --- /dev/null +++ b/packages/protocol-dashboard/src/components/MirrorImage/MirrorImage.tsx @@ -0,0 +1,60 @@ +import { useState, useEffect, useRef, ReactNode } from 'react' + +const TIMEOUT_MS = 3000 + +type MirrorImageProps = { + urls: string[] + alt: string + className?: string + fallback?: ReactNode + onLoad?: () => void +} + +const MirrorImage = ({ + urls = [], + alt = '', + className, + fallback = null, + onLoad +}: MirrorImageProps) => { + const [idx, setIdx] = useState(0) + const timerRef = useRef | null>(null) + + const firstUrl = urls[0] ?? null + useEffect(() => { + setIdx(0) + }, [firstUrl]) + + useEffect(() => { + if (!urls.length || idx >= urls.length) return + timerRef.current = setTimeout(() => setIdx((i) => i + 1), TIMEOUT_MS) + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + } + }, [idx, urls.length]) + + const handleLoad = () => { + if (timerRef.current) clearTimeout(timerRef.current) + onLoad?.() + } + + const handleError = () => { + if (timerRef.current) clearTimeout(timerRef.current) + setIdx((i) => i + 1) + } + + if (!urls.length || idx >= urls.length) return <>{fallback} + + return ( + {alt} + ) +} + +export default MirrorImage diff --git a/packages/protocol-dashboard/src/components/TopAlbums/TopAlbums.module.css b/packages/protocol-dashboard/src/components/TopAlbums/TopAlbums.module.css index 7033aa77b0f..9fc999ee260 100644 --- a/packages/protocol-dashboard/src/components/TopAlbums/TopAlbums.module.css +++ b/packages/protocol-dashboard/src/components/TopAlbums/TopAlbums.module.css @@ -52,12 +52,15 @@ width: 56px; border-radius: 4px; overflow: hidden; - background-size: cover; - background-position: center; - background-repeat: no-repeat; flex-shrink: 0; } +.artworkImg { + width: 100%; + height: 100%; + object-fit: cover; +} + .album .text { min-width: 0; flex-grow: 1; diff --git a/packages/protocol-dashboard/src/components/TopAlbums/TopAlbums.tsx b/packages/protocol-dashboard/src/components/TopAlbums/TopAlbums.tsx index a473ff08091..2d7cec4a2fd 100644 --- a/packages/protocol-dashboard/src/components/TopAlbums/TopAlbums.tsx +++ b/packages/protocol-dashboard/src/components/TopAlbums/TopAlbums.tsx @@ -2,9 +2,11 @@ import React, { useCallback } from 'react' import Error from 'components/Error' import Loading from 'components/Loading' +import MirrorImage from 'components/MirrorImage/MirrorImage' import Paper from 'components/Paper' import { useTopAlbums } from 'store/cache/music/hooks' import { MusicError } from 'store/cache/music/slice' +import { Playlist } from 'types' import styles from './TopAlbums.module.css' @@ -12,6 +14,14 @@ const messages = { title: 'Top Albums This Week' } +const AlbumArtwork = ({ album }: { album: Playlist }) => { + const urls = + album.artworkUrls.length > 0 ? album.artworkUrls : [album.artwork] + return ( + + ) +} + type TopAlbumsProps = {} const TopAlbums: React.FC = () => { @@ -25,13 +35,9 @@ const TopAlbums: React.FC = () => { return topAlbums ? ( topAlbums.map((p, i) => (
goToUrl(p.url)}> -
goToUrl(p.url)} - style={{ - backgroundImage: `url(${p.artwork})` - }} - /> +
goToUrl(p.url)}> + +
{p.title}
{p.handle}
diff --git a/packages/protocol-dashboard/src/components/TopPlaylists/TopPlaylists.module.css b/packages/protocol-dashboard/src/components/TopPlaylists/TopPlaylists.module.css index a428d38fe5f..924b050c694 100644 --- a/packages/protocol-dashboard/src/components/TopPlaylists/TopPlaylists.module.css +++ b/packages/protocol-dashboard/src/components/TopPlaylists/TopPlaylists.module.css @@ -52,12 +52,15 @@ width: 56px; border-radius: 4px; overflow: hidden; - background-size: cover; - background-position: center; - background-repeat: no-repeat; flex-shrink: 0; } +.artworkImg { + width: 100%; + height: 100%; + object-fit: cover; +} + .playlist .text { min-width: 0; flex-grow: 1; diff --git a/packages/protocol-dashboard/src/components/TopPlaylists/TopPlaylists.tsx b/packages/protocol-dashboard/src/components/TopPlaylists/TopPlaylists.tsx index 3fa6cd0737b..932babd334c 100644 --- a/packages/protocol-dashboard/src/components/TopPlaylists/TopPlaylists.tsx +++ b/packages/protocol-dashboard/src/components/TopPlaylists/TopPlaylists.tsx @@ -2,9 +2,11 @@ import React, { useCallback } from 'react' import Error from 'components/Error' import Loading from 'components/Loading' +import MirrorImage from 'components/MirrorImage/MirrorImage' import Paper from 'components/Paper' import { useTopPlaylists } from 'store/cache/music/hooks' import { MusicError } from 'store/cache/music/slice' +import { Playlist } from 'types' import styles from './TopPlaylists.module.css' @@ -12,6 +14,18 @@ const messages = { title: 'Top Playlists This Week' } +const PlaylistArtwork = ({ playlist }: { playlist: Playlist }) => { + const urls = + playlist.artworkUrls.length > 0 ? playlist.artworkUrls : [playlist.artwork] + return ( + + ) +} + type TopPlaylistsProps = {} const TopPlaylists: React.FC = () => { @@ -25,12 +39,9 @@ const TopPlaylists: React.FC = () => { return topPlaylists ? ( topPlaylists!.map((p, i) => (
goToUrl(p.url)}> -
+
+ +
{p.title}
{p.handle}
diff --git a/packages/protocol-dashboard/src/components/TopTracks/TopTracks.module.css b/packages/protocol-dashboard/src/components/TopTracks/TopTracks.module.css index 639af603db6..3f7a3018388 100644 --- a/packages/protocol-dashboard/src/components/TopTracks/TopTracks.module.css +++ b/packages/protocol-dashboard/src/components/TopTracks/TopTracks.module.css @@ -38,13 +38,16 @@ border-radius: 8px; overflow: hidden; margin-bottom: 8px; - background-size: cover; - background-position: center; - background-repeat: no-repeat; transition: all 0.17s ease-in-out; cursor: pointer; } +.artworkImg { + width: 100%; + height: 100%; + object-fit: cover; +} + .artwork:hover { transform: scale3d(1.04, 1.04, 1.04); } diff --git a/packages/protocol-dashboard/src/components/TopTracks/TopTracks.tsx b/packages/protocol-dashboard/src/components/TopTracks/TopTracks.tsx index 171f0a5b6a8..0dfc2675c13 100644 --- a/packages/protocol-dashboard/src/components/TopTracks/TopTracks.tsx +++ b/packages/protocol-dashboard/src/components/TopTracks/TopTracks.tsx @@ -2,9 +2,11 @@ import React, { useCallback } from 'react' import Error from 'components/Error' import Loading from 'components/Loading' +import MirrorImage from 'components/MirrorImage/MirrorImage' import Paper from 'components/Paper' import { useTopTracks } from 'store/cache/music/hooks' import { MusicError } from 'store/cache/music/slice' +import { Track } from 'types' import { createStyles } from 'utils/mobile' import desktopStyles from './TopTracks.module.css' @@ -16,6 +18,14 @@ const messages = { title: 'Top Tracks This Week' } +const TrackArtwork = ({ track }: { track: Track }) => { + const urls = + track.artworkUrls.length > 0 ? track.artworkUrls : [track.artwork] + return ( + + ) +} + type TopTracksProps = {} const TopTracks: React.FC = () => { @@ -29,13 +39,9 @@ const TopTracks: React.FC = () => { return topTracks ? ( topTracks.map((t, i) => (
-
goToUrl(t.url)} - style={{ - backgroundImage: `url(${t.artwork})` - }} - /> +
goToUrl(t.url)}> + +
goToUrl(t.url)}> {t.title}
diff --git a/packages/protocol-dashboard/src/components/TopTracks/TopTracksMobile.module.css b/packages/protocol-dashboard/src/components/TopTracks/TopTracksMobile.module.css index 37f3510c9de..c5b58db14e4 100644 --- a/packages/protocol-dashboard/src/components/TopTracks/TopTracksMobile.module.css +++ b/packages/protocol-dashboard/src/components/TopTracks/TopTracksMobile.module.css @@ -22,13 +22,16 @@ border-radius: 8px; overflow: hidden; margin-bottom: 8px; - background-size: cover; - background-position: center; - background-repeat: no-repeat; transition: all 0.17s ease-in-out; cursor: pointer; } +.artworkImg { + width: 100%; + height: 100%; + object-fit: cover; +} + .artwork:hover { transform: scale3d(1.04, 1.04, 1.04); } diff --git a/packages/protocol-dashboard/src/components/UserImage/UserImage.tsx b/packages/protocol-dashboard/src/components/UserImage/UserImage.tsx index 61011a4ab80..9ac5f1e4c62 100644 --- a/packages/protocol-dashboard/src/components/UserImage/UserImage.tsx +++ b/packages/protocol-dashboard/src/components/UserImage/UserImage.tsx @@ -2,8 +2,10 @@ import React, { useEffect, useState } from 'react' import clsx from 'clsx' +import MirrorImage from 'components/MirrorImage/MirrorImage' import { useUserProfile } from 'store/cache/user/hooks' import { Address } from 'types' +import { getImageUrls } from 'utils/imageUrls' import styles from './UserImage.module.css' @@ -16,14 +18,6 @@ type UserImageProps = { useSkeleton?: boolean } -const preload = async (image: string, cb: () => void) => { - await new Promise((resolve) => { - const i = new Image() - i.src = image - i.onload = cb - }) -} - const UserImage = ({ imgClassName, className, @@ -32,12 +26,18 @@ const UserImage = ({ hasLoaded, useSkeleton = true }: UserImageProps) => { - const { image } = useUserProfile({ wallet }) - const [preloaded, setPreloaded] = useState(false) + const { image, profilePicture } = useUserProfile({ wallet }) + const [loaded, setLoaded] = useState(false) + + // Build mirror URLs from the profilePicture object if available + const mirrorUrls = profilePicture + ? getImageUrls(profilePicture as Record) + : [] + + // Use mirror URLs if available, otherwise fall back to single image + const urls = mirrorUrls.length > 0 ? mirrorUrls : image ? [image] : [] + useEffect(() => { - if (image) { - preload(image, () => setPreloaded(true)) - } if (image && hasLoaded) hasLoaded() }, [image, hasLoaded]) @@ -47,12 +47,13 @@ const UserImage = ({ [styles.noSkeleton]: !useSkeleton })} > - {alt} 0 && loaded })} - src={image || undefined} - alt={alt} + onLoad={() => setLoaded(true)} />
) diff --git a/packages/protocol-dashboard/src/hooks/useConnectAudiusProfile.ts b/packages/protocol-dashboard/src/hooks/useConnectAudiusProfile.ts index de57b75c1bc..0a00c6c1f55 100644 --- a/packages/protocol-dashboard/src/hooks/useConnectAudiusProfile.ts +++ b/packages/protocol-dashboard/src/hooks/useConnectAudiusProfile.ts @@ -1,139 +1,305 @@ -import { useState } from 'react' +import { useState, useCallback } from 'react' -import { DecodedUserToken, OAUTH_URL } from '@audius/sdk' import { useQueryClient } from '@tanstack/react-query' +import { BrowserProvider } from 'ethers' import { useDispatch } from 'react-redux' import { getDashboardWalletUserQueryKey } from 'hooks/useDashboardWalletUsers' -import { audiusSdk as sdk } from 'services/Audius/sdk' +import { audiusSdk as sdk, apiEndpoint } from 'services/Audius/sdk' import { disableAudiusProfileRefetch } from 'store/account/slice' -const env = import.meta.env.VITE_ENVIRONMENT - -let resolveUserHandle = null -let receiveUserHandlePromise = null - -const receiveUserId = async (event: MessageEvent) => { - const oauthOrigin = new URL(OAUTH_URL[env]).origin - if ( - event.origin !== oauthOrigin || - event.source !== sdk.oauth.activePopupWindow || - !event.data.state - ) { - return - } - if (sdk.oauth.getCsrfToken() !== event.data.state) { - console.error('State mismatch.') - return - } - if (event.data.userHandle != null) { - resolveUserHandle(event.data.userHandle) - } +const API_KEY = '2cc593fc814461263d282a84286fd4f72c79562e' + +const AUDIUS_URL = import.meta.env.VITE_AUDIUS_URL || 'https://audius.co' +const OAUTH_BASE_URL = `${AUDIUS_URL}/oauth/auth` + +// --- PKCE helpers (inlined from @audius/sdk since they aren't exported) --- + +function base64url(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') +} + +function generateCodeVerifier(): string { + const arr = new Uint8Array(32) + globalThis.crypto.getRandomValues(arr) + return base64url(arr) +} + +async function generateCodeChallenge(verifier: string): Promise { + const data = new TextEncoder().encode(verifier) + const hash = await globalThis.crypto.subtle.digest('SHA-256', data) + return base64url(new Uint8Array(hash)) +} + +function generateState(): string { + const arr = new Uint8Array(16) + globalThis.crypto.getRandomValues(arr) + return base64url(arr) +} + +// --- End PKCE helpers --- + +type OAuthPopupMessage = { + state?: string + userHandle?: string + userId?: string + code?: string } export const useConnectAudiusProfile = ({ wallet, + walletProvider, onSuccess }: { wallet: string + walletProvider?: any onSuccess: () => void }) => { const queryClient = useQueryClient() const dispatch = useDispatch() const [isWaiting, setIsWaiting] = useState(false) - const handleConnectSuccess = async (profile: DecodedUserToken) => { - window.removeEventListener('message', receiveUserId) - // Optimistically set user - await queryClient.cancelQueries({ - queryKey: getDashboardWalletUserQueryKey(wallet) - }) - dispatch(disableAudiusProfileRefetch()) + + const connect = useCallback(async () => { + setIsWaiting(true) try { - const audiusUser = await sdk.users.getUser({ id: profile.userId }) - if (audiusUser?.data) { - queryClient.setQueryData(getDashboardWalletUserQueryKey(wallet), { - wallet, - user: audiusUser.data + // 1. Generate PKCE params + const state = generateState() + const codeVerifier = generateCodeVerifier() + const codeChallenge = await generateCodeChallenge(codeVerifier) + const origin = window.location.origin + + // 2. Construct OAuth URL with write scope + dashboard wallet tx + const params = new URLSearchParams({ + scope: 'write', + api_key: API_KEY, + state, + redirect_uri: 'postMessage', + origin, + response_type: 'code', + code_challenge: codeChallenge, + code_challenge_method: 'S256', + display: 'popup', + tx: 'connect_dashboard_wallet', + wallet + }) + const oauthUrl = `${OAUTH_BASE_URL}?${params.toString()}` + + // 3. Open popup + const popup = window.open( + oauthUrl, + '', + 'toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=375, height=785, top=100, left=100' + ) + if (!popup) { + throw new Error( + 'The login popup was blocked. Please allow popups for this site and try again.' + ) + } + + // 4. Wait for messages from popup + const oauthOrigin = new URL(OAUTH_BASE_URL).origin + + // First message: user handle (after user authenticates) + const { userHandle } = await new Promise<{ + userHandle: string + }>((resolve, reject) => { + const closeCheck = setInterval(() => { + if (popup.closed) { + clearInterval(closeCheck) + reject(new Error('The login popup was closed.')) + } + }, 500) + + const handler = (event: MessageEvent) => { + if ( + event.origin !== oauthOrigin || + event.source !== popup || + event.data?.state !== state + ) { + return + } + if (event.data.userHandle != null) { + window.removeEventListener('message', handler) + clearInterval(closeCheck) + resolve({ userHandle: event.data.userHandle }) + } + } + window.addEventListener('message', handler, false) + }) + + // 5. Sign wallet signature using ethers + if (!walletProvider) { + throw new Error('Wallet provider not available') + } + const provider = new BrowserProvider(walletProvider) + const signer = await provider.getSigner() + const timestamp = Math.round(new Date().getTime() / 1000) + const message = `Connecting Audius user @${userHandle} at ${timestamp}` + const signature = await signer.signMessage(message) + + // 6. Send wallet signature back to popup + popup.postMessage( + { + state, + walletSignature: { message, signature } + }, + oauthOrigin + ) + + // 7. Wait for auth code from popup (after EntityManager tx completes) + const { code } = await new Promise<{ code: string }>( + (resolve, reject) => { + const closeCheck = setInterval(() => { + if (popup.closed) { + clearInterval(closeCheck) + reject(new Error('The login popup was closed.')) + } + }, 500) + + const handler = (event: MessageEvent) => { + if ( + event.origin !== oauthOrigin || + event.source !== popup || + event.data?.state !== state + ) { + return + } + if (event.data.code != null) { + window.removeEventListener('message', handler) + clearInterval(closeCheck) + resolve({ code: event.data.code }) + } + } + window.addEventListener('message', handler, false) + } + ) + + // 8. Exchange code for tokens + const tokenRes = await fetch(`${apiEndpoint}/v1/oauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'authorization_code', + code, + code_verifier: codeVerifier, + client_id: API_KEY, + redirect_uri: 'postMessage' }) + }) + + if (!tokenRes.ok) { + throw new Error('Token exchange failed') } + + const tokens = await tokenRes.json() + + // 9. Fetch user profile and update cache + const meRes = await fetch(`${apiEndpoint}/v1/me`, { + headers: { Authorization: `Bearer ${tokens.access_token}` } + }) + if (meRes.ok) { + const { data: audiusUser } = await meRes.json() + if (audiusUser) { + await queryClient.cancelQueries({ + queryKey: getDashboardWalletUserQueryKey(wallet) + }) + dispatch(disableAudiusProfileRefetch()) + queryClient.setQueryData(getDashboardWalletUserQueryKey(wallet), { + wallet, + user: audiusUser + }) + } + } + setIsWaiting(false) onSuccess() - } catch { - console.error("Couldn't fetch Audius profile data.") + } catch (e) { + console.error('Connect Audius profile failed:', e) setIsWaiting(false) } - } + }, [wallet, walletProvider, queryClient, dispatch, onSuccess]) - const connect = async () => { + const disconnect = useCallback(async () => { setIsWaiting(true) - sdk.oauth.init({ - env, - successCallback: handleConnectSuccess, - errorCallback: (errorMessage: string) => { - window.removeEventListener('message', receiveUserId) - console.error(errorMessage) - setIsWaiting(false) - } - }) - window.removeEventListener('message', receiveUserId) - receiveUserHandlePromise = new Promise((resolve) => { - resolveUserHandle = resolve - }) - window.addEventListener('message', receiveUserId, false) - sdk.oauth.login({ - scope: 'write_once', - params: { - tx: 'connect_dashboard_wallet', - wallet - } - }) - - // Leg 1: Receive Audius user id from OAuth popup - const userHandle = await receiveUserHandlePromise - // Sign wallet signature from EM transaction - const message = `Connecting Audius user @${userHandle} at ${Math.round( - new Date().getTime() / 1000 - )}` - const signature = await window.audiusLibs.web3Manager.sign(message) - - const walletSignature = { message, signature } - // Leg 2: Send wallet signature to OAuth popup - sdk.oauth.activePopupWindow.postMessage( - { state: sdk.oauth.getCsrfToken(), walletSignature }, - new URL(OAUTH_URL[env]).origin - ) - } - - const handleDisconnectSuccess = async () => { - // Optimistically clear the connected user - await queryClient.cancelQueries({ - queryKey: getDashboardWalletUserQueryKey(wallet) - }) - dispatch(disableAudiusProfileRefetch()) - queryClient.setQueryData(getDashboardWalletUserQueryKey(wallet), null) - setIsWaiting(false) - onSuccess() - } - - const disconnect = async () => { - setIsWaiting(true) - sdk.oauth.init({ - env, - successCallback: handleDisconnectSuccess, - errorCallback: (errorMessage: string) => { - console.error(errorMessage) - setIsWaiting(false) - } - }) - sdk.oauth.login({ - scope: 'write_once', - params: { + + try { + // Generate PKCE params + const state = generateState() + const codeVerifier = generateCodeVerifier() + const codeChallenge = await generateCodeChallenge(codeVerifier) + const origin = window.location.origin + + // Construct OAuth URL for disconnect + const params = new URLSearchParams({ + scope: 'write', + api_key: API_KEY, + state, + redirect_uri: 'postMessage', + origin, + response_type: 'code', + code_challenge: codeChallenge, + code_challenge_method: 'S256', + display: 'popup', tx: 'disconnect_dashboard_wallet', wallet + }) + const oauthUrl = `${OAUTH_BASE_URL}?${params.toString()}` + + // Open popup + const popup = window.open( + oauthUrl, + '', + 'toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=375, height=785, top=100, left=100' + ) + if (!popup) { + throw new Error('The login popup was blocked.') } - }) - } + + const oauthOrigin = new URL(OAUTH_BASE_URL).origin + + // Wait for auth code (disconnect doesn't need wallet signature exchange) + await new Promise((resolve, reject) => { + const closeCheck = setInterval(() => { + if (popup.closed) { + clearInterval(closeCheck) + reject(new Error('The login popup was closed.')) + } + }, 500) + + const handler = (event: MessageEvent) => { + if ( + event.origin !== oauthOrigin || + event.source !== popup || + event.data?.state !== state + ) { + return + } + if (event.data.code != null) { + window.removeEventListener('message', handler) + clearInterval(closeCheck) + resolve() + } + } + window.addEventListener('message', handler, false) + }) + + // Optimistically clear the connected user + await queryClient.cancelQueries({ + queryKey: getDashboardWalletUserQueryKey(wallet) + }) + dispatch(disableAudiusProfileRefetch()) + queryClient.setQueryData(getDashboardWalletUserQueryKey(wallet), null) + setIsWaiting(false) + onSuccess() + } catch (e) { + console.error('Disconnect Audius profile failed:', e) + setIsWaiting(false) + } + }, [wallet, queryClient, dispatch, onSuccess]) return { connect, disconnect, isWaiting } } diff --git a/packages/protocol-dashboard/src/services/Audius/sdk.ts b/packages/protocol-dashboard/src/services/Audius/sdk.ts index 94283dd5f87..0ccc8a599c1 100644 --- a/packages/protocol-dashboard/src/services/Audius/sdk.ts +++ b/packages/protocol-dashboard/src/services/Audius/sdk.ts @@ -7,6 +7,7 @@ const apiEndpoint = sdkConfig.network.apiEndpoint const audiusSdk = sdk({ appName: 'Audius Protocol Dashboard', + apiKey: '2cc593fc814461263d282a84286fd4f72c79562e', environment: env }) diff --git a/packages/protocol-dashboard/src/store/cache/music/hooks.ts b/packages/protocol-dashboard/src/store/cache/music/hooks.ts index 33f433eb20e..a90224c3ce9 100644 --- a/packages/protocol-dashboard/src/store/cache/music/hooks.ts +++ b/packages/protocol-dashboard/src/store/cache/music/hooks.ts @@ -10,6 +10,7 @@ import Audius from 'services/Audius' import { audiusSdk } from 'services/Audius/sdk' import AppState from 'store/types' import { Playlist, Track } from 'types' +import { getImageUrls } from 'utils/imageUrls' import { MusicError, @@ -42,6 +43,7 @@ export function fetchTopTracks(): ThunkAction< title: d.title, handle: d.user.handle, artwork: d.artwork?._480x480 ?? imageBlank, + artworkUrls: getImageUrls(d.artwork as Record), url: `${AUDIUS_URL}/tracks/${d.id}`, userUrl: `${AUDIUS_URL}/users/${d.user.id}` })) @@ -70,6 +72,7 @@ export function fetchTopPlaylists(): ThunkAction< title: d.playlistName, handle: d.user.handle, artwork: d.artwork?._480x480 ?? imageBlank, + artworkUrls: getImageUrls(d.artwork as Record), plays: d.totalPlayCount, url: `${AUDIUS_URL}/playlists/${d.id}` })) @@ -99,6 +102,7 @@ export function fetchTopAlbums(): ThunkAction< title: d.playlistName, handle: d.user.handle, artwork: d.artwork?._480x480 ?? imageBlank, + artworkUrls: getImageUrls(d.artwork as Record), plays: d.totalPlayCount, url: `${AUDIUS_URL}/playlists/${d.id}` })) diff --git a/packages/protocol-dashboard/src/store/cache/user/hooks.ts b/packages/protocol-dashboard/src/store/cache/user/hooks.ts index 854683663ec..74ef3939a6b 100644 --- a/packages/protocol-dashboard/src/store/cache/user/hooks.ts +++ b/packages/protocol-dashboard/src/store/cache/user/hooks.ts @@ -476,6 +476,9 @@ export const useUserProfile = ({ wallet }: UseUserProfile) => { ? (audiusProfile?.profilePicture?._480x480 ?? user.image) : undefined + const profilePicture = + status !== Status.Loading ? (audiusProfile?.profilePicture ?? null) : null + const dispatch: ThunkDispatch = useDispatch() useEffect(() => { if (user && !inFlight.has(wallet)) { @@ -485,7 +488,12 @@ export const useUserProfile = ({ wallet }: UseUserProfile) => { }, [dispatch, user, wallet]) if (user) { - return { image, name: audiusProfile?.name ?? user.name, status } + return { + image, + profilePicture, + name: audiusProfile?.name ?? user.name, + status + } } return {} } diff --git a/packages/protocol-dashboard/src/types.ts b/packages/protocol-dashboard/src/types.ts index 8f868574cec..e9ed71251c5 100644 --- a/packages/protocol-dashboard/src/types.ts +++ b/packages/protocol-dashboard/src/types.ts @@ -251,6 +251,7 @@ export type Track = { title: string handle: string artwork: string + artworkUrls: string[] url: string userUrl: string } @@ -259,6 +260,7 @@ export type Playlist = { title: string handle: string artwork: string + artworkUrls: string[] plays: number url: string } @@ -267,6 +269,7 @@ export type Album = { title: string handle: string artwork: string + artworkUrls: string[] plays: number url: string } diff --git a/packages/protocol-dashboard/src/utils/imageUrls.ts b/packages/protocol-dashboard/src/utils/imageUrls.ts new file mode 100644 index 00000000000..1e219fc8f6a --- /dev/null +++ b/packages/protocol-dashboard/src/utils/imageUrls.ts @@ -0,0 +1,23 @@ +/** + * Builds a full URL list from an Audius API artwork/profilePicture object. + * Primary URL first, then each mirror host with the same path. + */ +export function getImageUrls( + artworkObj: Record | null | undefined, + size = '_480x480' +): string[] { + if (!artworkObj) return [] + const primary: string | undefined = artworkObj[size] || artworkObj['_150x150'] + if (!primary) return [] + const mirrors: string[] = Array.isArray(artworkObj.mirrors) + ? artworkObj.mirrors + : [] + if (!mirrors.length) return [primary] + let path: string + try { + path = new URL(primary).pathname + } catch { + return [primary] + } + return [primary, ...mirrors.map((root) => root.replace(/\/$/, '') + path)] +} diff --git a/packages/web/src/pages/oauth-login-page/hooks.ts b/packages/web/src/pages/oauth-login-page/hooks.ts index 984ef2ba039..a7a054800d0 100644 --- a/packages/web/src/pages/oauth-login-page/hooks.ts +++ b/packages/web/src/pages/oauth-login-page/hooks.ts @@ -153,6 +153,19 @@ const useParsedQueryParams = () => { error = messages.invalidCodeChallengeMethodError } } + // Optional dashboard wallet tx params (write scope also supports tx param) + if (!error && tx) { + const { error: txParamsError, txParams: txParamsRes } = + validateWriteOnceParams({ + tx, + params: rest, + willUsePostMessage: parsedRedirectUri === 'postmessage' + }) + txParams = txParamsRes + if (txParamsError) { + error = txParamsError + } + } } else if (scope === 'write_once') { // Write-once scope-specific validations: const { error: writeOnceParamsError, txParams: txParamsRes } = @@ -533,6 +546,33 @@ export const useOAuthSetup = ({ }) return } + + // Handle dashboard wallet tx if present with write scope + if (tx && txParams) { + if ((tx as WriteOnceTx) === 'connect_dashboard_wallet') { + const success = await handleAuthorizeConnectDashboardWallet({ + state, + originUrl: parsedOrigin, + onError, + onWaitForWalletSignature: onPendingTransactionApproval, + onReceivedWalletSignature: onReceiveTransactionApproval, + account, + txParams: txParams! + }) + if (!success) { + return + } + } else if ((tx as WriteOnceTx) === 'disconnect_dashboard_wallet') { + const success = await handleAuthorizeDisconnectDashboardWallet({ + account, + txParams: txParams!, + onError + }) + if (!success) { + return + } + } + } } else if (scope === 'write_once') { // Note: Tx = 'connect_dashboard_wallet' since that's the only option available right now for write_once scope if ((tx as WriteOnceTx) === 'connect_dashboard_wallet') { From 100fd5a873a03632bdd10efe59a23ed57f1f999d Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Sat, 4 Apr 2026 00:19:38 -0700 Subject: [PATCH 2/5] Fix protocol dashboard OAuth flow, image mirrors, and build issues - Fix walletProvider not being passed through component chain - Use EIP-1193 personal_sign instead of ethers BrowserProvider - Add esbuild plugin to resolve ethers v6 for @web3modal (fixes dep optimization crash) - Inline badgeTiers to avoid circular dependency from @audius/common/store - Remove debug logging Co-Authored-By: Claude Opus 4.6 --- .../ConnectAudiusProfileModal.tsx | 2 +- .../components/TopAlbums/TopAlbums.module.css | 9 +- .../src/components/TopAlbums/TopAlbums.tsx | 20 ++- .../TopPlaylists/TopPlaylists.module.css | 9 +- .../components/TopPlaylists/TopPlaylists.tsx | 23 +--- .../components/TopTracks/TopTracks.module.css | 9 +- .../src/components/TopTracks/TopTracks.tsx | 20 ++- .../TopTracks/TopTracksMobile.module.css | 9 +- .../src/components/UserImage/UserImage.tsx | 35 +++--- .../UserInfo/AudiusProfileBadges.tsx | 11 +- .../src/hooks/useConnectAudiusProfile.ts | 119 ++++++++---------- .../src/services/Audius/sdk.ts | 1 - .../src/store/cache/music/hooks.ts | 4 - .../src/store/cache/user/hooks.ts | 10 +- packages/protocol-dashboard/src/types.ts | 3 - packages/protocol-dashboard/vite.config.ts | 22 +++- 16 files changed, 136 insertions(+), 170 deletions(-) diff --git a/packages/protocol-dashboard/src/components/ConnectAudiusProfileModal/ConnectAudiusProfileModal.tsx b/packages/protocol-dashboard/src/components/ConnectAudiusProfileModal/ConnectAudiusProfileModal.tsx index 1bb31199dce..8464439da86 100644 --- a/packages/protocol-dashboard/src/components/ConnectAudiusProfileModal/ConnectAudiusProfileModal.tsx +++ b/packages/protocol-dashboard/src/components/ConnectAudiusProfileModal/ConnectAudiusProfileModal.tsx @@ -13,7 +13,7 @@ const messages = { connectAudiusProfileDescriptionP1: 'Help other users identify you by connecting your Audius account.', connectAudiusProfileDescriptionP2: - "Once you've linked your Audius account, your Profile Picture and Display Name will be visible to users throughout the protocol dashboard.", + 'Once you’ve linked your Audius account, your Profile Picture and Display Name will be visible to users throughout the protocol dashboard.', connectProfileButton: 'Connect Profile', disconnectAudiusProfileTitle: 'Disconnect Audius Profile', disconnectProfileButton: 'Disconnect Audius Profile', diff --git a/packages/protocol-dashboard/src/components/TopAlbums/TopAlbums.module.css b/packages/protocol-dashboard/src/components/TopAlbums/TopAlbums.module.css index 9fc999ee260..7033aa77b0f 100644 --- a/packages/protocol-dashboard/src/components/TopAlbums/TopAlbums.module.css +++ b/packages/protocol-dashboard/src/components/TopAlbums/TopAlbums.module.css @@ -52,15 +52,12 @@ width: 56px; border-radius: 4px; overflow: hidden; + background-size: cover; + background-position: center; + background-repeat: no-repeat; flex-shrink: 0; } -.artworkImg { - width: 100%; - height: 100%; - object-fit: cover; -} - .album .text { min-width: 0; flex-grow: 1; diff --git a/packages/protocol-dashboard/src/components/TopAlbums/TopAlbums.tsx b/packages/protocol-dashboard/src/components/TopAlbums/TopAlbums.tsx index 2d7cec4a2fd..a473ff08091 100644 --- a/packages/protocol-dashboard/src/components/TopAlbums/TopAlbums.tsx +++ b/packages/protocol-dashboard/src/components/TopAlbums/TopAlbums.tsx @@ -2,11 +2,9 @@ import React, { useCallback } from 'react' import Error from 'components/Error' import Loading from 'components/Loading' -import MirrorImage from 'components/MirrorImage/MirrorImage' import Paper from 'components/Paper' import { useTopAlbums } from 'store/cache/music/hooks' import { MusicError } from 'store/cache/music/slice' -import { Playlist } from 'types' import styles from './TopAlbums.module.css' @@ -14,14 +12,6 @@ const messages = { title: 'Top Albums This Week' } -const AlbumArtwork = ({ album }: { album: Playlist }) => { - const urls = - album.artworkUrls.length > 0 ? album.artworkUrls : [album.artwork] - return ( - - ) -} - type TopAlbumsProps = {} const TopAlbums: React.FC = () => { @@ -35,9 +25,13 @@ const TopAlbums: React.FC = () => { return topAlbums ? ( topAlbums.map((p, i) => (
goToUrl(p.url)}> -
goToUrl(p.url)}> - -
+
goToUrl(p.url)} + style={{ + backgroundImage: `url(${p.artwork})` + }} + />
{p.title}
{p.handle}
diff --git a/packages/protocol-dashboard/src/components/TopPlaylists/TopPlaylists.module.css b/packages/protocol-dashboard/src/components/TopPlaylists/TopPlaylists.module.css index 924b050c694..a428d38fe5f 100644 --- a/packages/protocol-dashboard/src/components/TopPlaylists/TopPlaylists.module.css +++ b/packages/protocol-dashboard/src/components/TopPlaylists/TopPlaylists.module.css @@ -52,15 +52,12 @@ width: 56px; border-radius: 4px; overflow: hidden; + background-size: cover; + background-position: center; + background-repeat: no-repeat; flex-shrink: 0; } -.artworkImg { - width: 100%; - height: 100%; - object-fit: cover; -} - .playlist .text { min-width: 0; flex-grow: 1; diff --git a/packages/protocol-dashboard/src/components/TopPlaylists/TopPlaylists.tsx b/packages/protocol-dashboard/src/components/TopPlaylists/TopPlaylists.tsx index 932babd334c..3fa6cd0737b 100644 --- a/packages/protocol-dashboard/src/components/TopPlaylists/TopPlaylists.tsx +++ b/packages/protocol-dashboard/src/components/TopPlaylists/TopPlaylists.tsx @@ -2,11 +2,9 @@ import React, { useCallback } from 'react' import Error from 'components/Error' import Loading from 'components/Loading' -import MirrorImage from 'components/MirrorImage/MirrorImage' import Paper from 'components/Paper' import { useTopPlaylists } from 'store/cache/music/hooks' import { MusicError } from 'store/cache/music/slice' -import { Playlist } from 'types' import styles from './TopPlaylists.module.css' @@ -14,18 +12,6 @@ const messages = { title: 'Top Playlists This Week' } -const PlaylistArtwork = ({ playlist }: { playlist: Playlist }) => { - const urls = - playlist.artworkUrls.length > 0 ? playlist.artworkUrls : [playlist.artwork] - return ( - - ) -} - type TopPlaylistsProps = {} const TopPlaylists: React.FC = () => { @@ -39,9 +25,12 @@ const TopPlaylists: React.FC = () => { return topPlaylists ? ( topPlaylists!.map((p, i) => (
goToUrl(p.url)}> -
- -
+
{p.title}
{p.handle}
diff --git a/packages/protocol-dashboard/src/components/TopTracks/TopTracks.module.css b/packages/protocol-dashboard/src/components/TopTracks/TopTracks.module.css index 3f7a3018388..639af603db6 100644 --- a/packages/protocol-dashboard/src/components/TopTracks/TopTracks.module.css +++ b/packages/protocol-dashboard/src/components/TopTracks/TopTracks.module.css @@ -38,16 +38,13 @@ border-radius: 8px; overflow: hidden; margin-bottom: 8px; + background-size: cover; + background-position: center; + background-repeat: no-repeat; transition: all 0.17s ease-in-out; cursor: pointer; } -.artworkImg { - width: 100%; - height: 100%; - object-fit: cover; -} - .artwork:hover { transform: scale3d(1.04, 1.04, 1.04); } diff --git a/packages/protocol-dashboard/src/components/TopTracks/TopTracks.tsx b/packages/protocol-dashboard/src/components/TopTracks/TopTracks.tsx index 0dfc2675c13..171f0a5b6a8 100644 --- a/packages/protocol-dashboard/src/components/TopTracks/TopTracks.tsx +++ b/packages/protocol-dashboard/src/components/TopTracks/TopTracks.tsx @@ -2,11 +2,9 @@ import React, { useCallback } from 'react' import Error from 'components/Error' import Loading from 'components/Loading' -import MirrorImage from 'components/MirrorImage/MirrorImage' import Paper from 'components/Paper' import { useTopTracks } from 'store/cache/music/hooks' import { MusicError } from 'store/cache/music/slice' -import { Track } from 'types' import { createStyles } from 'utils/mobile' import desktopStyles from './TopTracks.module.css' @@ -18,14 +16,6 @@ const messages = { title: 'Top Tracks This Week' } -const TrackArtwork = ({ track }: { track: Track }) => { - const urls = - track.artworkUrls.length > 0 ? track.artworkUrls : [track.artwork] - return ( - - ) -} - type TopTracksProps = {} const TopTracks: React.FC = () => { @@ -39,9 +29,13 @@ const TopTracks: React.FC = () => { return topTracks ? ( topTracks.map((t, i) => (
-
goToUrl(t.url)}> - -
+
goToUrl(t.url)} + style={{ + backgroundImage: `url(${t.artwork})` + }} + />
goToUrl(t.url)}> {t.title}
diff --git a/packages/protocol-dashboard/src/components/TopTracks/TopTracksMobile.module.css b/packages/protocol-dashboard/src/components/TopTracks/TopTracksMobile.module.css index c5b58db14e4..37f3510c9de 100644 --- a/packages/protocol-dashboard/src/components/TopTracks/TopTracksMobile.module.css +++ b/packages/protocol-dashboard/src/components/TopTracks/TopTracksMobile.module.css @@ -22,16 +22,13 @@ border-radius: 8px; overflow: hidden; margin-bottom: 8px; + background-size: cover; + background-position: center; + background-repeat: no-repeat; transition: all 0.17s ease-in-out; cursor: pointer; } -.artworkImg { - width: 100%; - height: 100%; - object-fit: cover; -} - .artwork:hover { transform: scale3d(1.04, 1.04, 1.04); } diff --git a/packages/protocol-dashboard/src/components/UserImage/UserImage.tsx b/packages/protocol-dashboard/src/components/UserImage/UserImage.tsx index 9ac5f1e4c62..61011a4ab80 100644 --- a/packages/protocol-dashboard/src/components/UserImage/UserImage.tsx +++ b/packages/protocol-dashboard/src/components/UserImage/UserImage.tsx @@ -2,10 +2,8 @@ import React, { useEffect, useState } from 'react' import clsx from 'clsx' -import MirrorImage from 'components/MirrorImage/MirrorImage' import { useUserProfile } from 'store/cache/user/hooks' import { Address } from 'types' -import { getImageUrls } from 'utils/imageUrls' import styles from './UserImage.module.css' @@ -18,6 +16,14 @@ type UserImageProps = { useSkeleton?: boolean } +const preload = async (image: string, cb: () => void) => { + await new Promise((resolve) => { + const i = new Image() + i.src = image + i.onload = cb + }) +} + const UserImage = ({ imgClassName, className, @@ -26,18 +32,12 @@ const UserImage = ({ hasLoaded, useSkeleton = true }: UserImageProps) => { - const { image, profilePicture } = useUserProfile({ wallet }) - const [loaded, setLoaded] = useState(false) - - // Build mirror URLs from the profilePicture object if available - const mirrorUrls = profilePicture - ? getImageUrls(profilePicture as Record) - : [] - - // Use mirror URLs if available, otherwise fall back to single image - const urls = mirrorUrls.length > 0 ? mirrorUrls : image ? [image] : [] - + const { image } = useUserProfile({ wallet }) + const [preloaded, setPreloaded] = useState(false) useEffect(() => { + if (image) { + preload(image, () => setPreloaded(true)) + } if (image && hasLoaded) hasLoaded() }, [image, hasLoaded]) @@ -47,13 +47,12 @@ const UserImage = ({ [styles.noSkeleton]: !useSkeleton })} > - 0 && loaded + [styles.show]: !!image && preloaded })} - onLoad={() => setLoaded(true)} + src={image || undefined} + alt={alt} />
) diff --git a/packages/protocol-dashboard/src/components/UserInfo/AudiusProfileBadges.tsx b/packages/protocol-dashboard/src/components/UserInfo/AudiusProfileBadges.tsx index 4a68d0bc2c5..d7da1c62ab2 100644 --- a/packages/protocol-dashboard/src/components/UserInfo/AudiusProfileBadges.tsx +++ b/packages/protocol-dashboard/src/components/UserInfo/AudiusProfileBadges.tsx @@ -1,8 +1,17 @@ import { cloneElement, ReactElement } from 'react' import { BadgeTier } from '@audius/common/models' -import { badgeTiers } from '@audius/common/store' import { Nullable } from '@audius/common/utils' + +// Inlined from @audius/common/store to avoid circular dependency +// (store/wallet/utils → api barrel → upload modules → store) +const badgeTiers: { tier: BadgeTier; humanReadableAmount: number }[] = [ + { tier: 'platinum', humanReadableAmount: 10000 }, + { tier: 'gold', humanReadableAmount: 1000 }, + { tier: 'silver', humanReadableAmount: 100 }, + { tier: 'bronze', humanReadableAmount: 10 }, + { tier: 'none', humanReadableAmount: 0 } +] import { User } from '@audius/sdk' import cn from 'classnames' diff --git a/packages/protocol-dashboard/src/hooks/useConnectAudiusProfile.ts b/packages/protocol-dashboard/src/hooks/useConnectAudiusProfile.ts index 0a00c6c1f55..85661108a7a 100644 --- a/packages/protocol-dashboard/src/hooks/useConnectAudiusProfile.ts +++ b/packages/protocol-dashboard/src/hooks/useConnectAudiusProfile.ts @@ -1,11 +1,10 @@ import { useState, useCallback } from 'react' import { useQueryClient } from '@tanstack/react-query' -import { BrowserProvider } from 'ethers' import { useDispatch } from 'react-redux' import { getDashboardWalletUserQueryKey } from 'hooks/useDashboardWalletUsers' -import { audiusSdk as sdk, apiEndpoint } from 'services/Audius/sdk' +import { audiusSdk, apiEndpoint } from 'services/Audius/sdk' import { disableAudiusProfileRefetch } from 'store/account/slice' const API_KEY = '2cc593fc814461263d282a84286fd4f72c79562e' @@ -13,7 +12,7 @@ const API_KEY = '2cc593fc814461263d282a84286fd4f72c79562e' const AUDIUS_URL = import.meta.env.VITE_AUDIUS_URL || 'https://audius.co' const OAUTH_BASE_URL = `${AUDIUS_URL}/oauth/auth` -// --- PKCE helpers (inlined from @audius/sdk since they aren't exported) --- +// --- PKCE helpers --- function base64url(bytes: Uint8Array): string { return btoa(String.fromCharCode(...bytes)) @@ -40,9 +39,7 @@ function generateState(): string { return base64url(arr) } -// --- End PKCE helpers --- - -type OAuthPopupMessage = { +type PopupMessage = { state?: string userHandle?: string userId?: string @@ -66,13 +63,12 @@ export const useConnectAudiusProfile = ({ setIsWaiting(true) try { - // 1. Generate PKCE params const state = generateState() const codeVerifier = generateCodeVerifier() const codeChallenge = await generateCodeChallenge(codeVerifier) const origin = window.location.origin + const oauthOrigin = new URL(OAUTH_BASE_URL).origin - // 2. Construct OAuth URL with write scope + dashboard wallet tx const params = new URLSearchParams({ scope: 'write', api_key: API_KEY, @@ -86,71 +82,67 @@ export const useConnectAudiusProfile = ({ tx: 'connect_dashboard_wallet', wallet }) - const oauthUrl = `${OAUTH_BASE_URL}?${params.toString()}` - // 3. Open popup const popup = window.open( - oauthUrl, + `${OAUTH_BASE_URL}?${params.toString()}`, '', 'toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=375, height=785, top=100, left=100' ) if (!popup) { - throw new Error( - 'The login popup was blocked. Please allow popups for this site and try again.' - ) + throw new Error('The login popup was blocked.') } - // 4. Wait for messages from popup - const oauthOrigin = new URL(OAUTH_BASE_URL).origin - - // First message: user handle (after user authenticates) - const { userHandle } = await new Promise<{ - userHandle: string - }>((resolve, reject) => { - const closeCheck = setInterval(() => { - if (popup.closed) { - clearInterval(closeCheck) - reject(new Error('The login popup was closed.')) - } - }, 500) + // Listen for ALL messages from popup (userHandle, then code) + const { userHandle } = await new Promise<{ userHandle: string }>( + (resolve, reject) => { + const closeCheck = setInterval(() => { + if (popup.closed) { + clearInterval(closeCheck) + reject(new Error('The login popup was closed.')) + } + }, 500) - const handler = (event: MessageEvent) => { - if ( - event.origin !== oauthOrigin || - event.source !== popup || - event.data?.state !== state - ) { - return - } - if (event.data.userHandle != null) { - window.removeEventListener('message', handler) - clearInterval(closeCheck) - resolve({ userHandle: event.data.userHandle }) + const handler = (event: MessageEvent) => { + if ( + event.origin !== oauthOrigin || + event.source !== popup || + event.data?.state !== state + ) { + return + } + if (event.data.userHandle != null) { + window.removeEventListener('message', handler) + clearInterval(closeCheck) + resolve({ userHandle: event.data.userHandle }) + } } + window.addEventListener('message', handler, false) } - window.addEventListener('message', handler, false) - }) + ) - // 5. Sign wallet signature using ethers + // Sign with connected Ethereum wallet if (!walletProvider) { throw new Error('Wallet provider not available') } - const provider = new BrowserProvider(walletProvider) - const signer = await provider.getSigner() const timestamp = Math.round(new Date().getTime() / 1000) const message = `Connecting Audius user @${userHandle} at ${timestamp}` - const signature = await signer.signMessage(message) + const hexMessage = + '0x' + + Array.from(new TextEncoder().encode(message)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + const signature = await walletProvider.request({ + method: 'personal_sign', + params: [hexMessage, wallet] + }) - // 6. Send wallet signature back to popup + // Send wallet signature to popup for EntityManager tx popup.postMessage( - { - state, - walletSignature: { message, signature } - }, + { state, walletSignature: { message, signature } }, oauthOrigin ) - // 7. Wait for auth code from popup (after EntityManager tx completes) + // Wait for auth code from popup (after EntityManager tx + PKCE exchange) const { code } = await new Promise<{ code: string }>( (resolve, reject) => { const closeCheck = setInterval(() => { @@ -160,7 +152,7 @@ export const useConnectAudiusProfile = ({ } }, 500) - const handler = (event: MessageEvent) => { + const handler = (event: MessageEvent) => { if ( event.origin !== oauthOrigin || event.source !== popup || @@ -178,7 +170,7 @@ export const useConnectAudiusProfile = ({ } ) - // 8. Exchange code for tokens + // Exchange code for tokens const tokenRes = await fetch(`${apiEndpoint}/v1/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -192,12 +184,12 @@ export const useConnectAudiusProfile = ({ }) if (!tokenRes.ok) { - throw new Error('Token exchange failed') + throw new Error(`Token exchange failed: ${tokenRes.status}`) } const tokens = await tokenRes.json() - // 9. Fetch user profile and update cache + // Fetch user profile and update cache const meRes = await fetch(`${apiEndpoint}/v1/me`, { headers: { Authorization: `Bearer ${tokens.access_token}` } }) @@ -215,6 +207,7 @@ export const useConnectAudiusProfile = ({ } } + popup.close() setIsWaiting(false) onSuccess() } catch (e) { @@ -227,13 +220,12 @@ export const useConnectAudiusProfile = ({ setIsWaiting(true) try { - // Generate PKCE params const state = generateState() const codeVerifier = generateCodeVerifier() const codeChallenge = await generateCodeChallenge(codeVerifier) const origin = window.location.origin + const oauthOrigin = new URL(OAUTH_BASE_URL).origin - // Construct OAuth URL for disconnect const params = new URLSearchParams({ scope: 'write', api_key: API_KEY, @@ -247,11 +239,9 @@ export const useConnectAudiusProfile = ({ tx: 'disconnect_dashboard_wallet', wallet }) - const oauthUrl = `${OAUTH_BASE_URL}?${params.toString()}` - // Open popup const popup = window.open( - oauthUrl, + `${AUDIUS_URL}/oauth/auth?${params.toString()}`, '', 'toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=375, height=785, top=100, left=100' ) @@ -259,9 +249,7 @@ export const useConnectAudiusProfile = ({ throw new Error('The login popup was blocked.') } - const oauthOrigin = new URL(OAUTH_BASE_URL).origin - - // Wait for auth code (disconnect doesn't need wallet signature exchange) + // Wait for auth code (disconnect doesn't need wallet signature) await new Promise((resolve, reject) => { const closeCheck = setInterval(() => { if (popup.closed) { @@ -270,7 +258,7 @@ export const useConnectAudiusProfile = ({ } }, 500) - const handler = (event: MessageEvent) => { + const handler = (event: MessageEvent) => { if ( event.origin !== oauthOrigin || event.source !== popup || @@ -287,12 +275,13 @@ export const useConnectAudiusProfile = ({ window.addEventListener('message', handler, false) }) - // Optimistically clear the connected user + // Clear the connected user await queryClient.cancelQueries({ queryKey: getDashboardWalletUserQueryKey(wallet) }) dispatch(disableAudiusProfileRefetch()) queryClient.setQueryData(getDashboardWalletUserQueryKey(wallet), null) + popup.close() setIsWaiting(false) onSuccess() } catch (e) { diff --git a/packages/protocol-dashboard/src/services/Audius/sdk.ts b/packages/protocol-dashboard/src/services/Audius/sdk.ts index 0ccc8a599c1..94283dd5f87 100644 --- a/packages/protocol-dashboard/src/services/Audius/sdk.ts +++ b/packages/protocol-dashboard/src/services/Audius/sdk.ts @@ -7,7 +7,6 @@ const apiEndpoint = sdkConfig.network.apiEndpoint const audiusSdk = sdk({ appName: 'Audius Protocol Dashboard', - apiKey: '2cc593fc814461263d282a84286fd4f72c79562e', environment: env }) diff --git a/packages/protocol-dashboard/src/store/cache/music/hooks.ts b/packages/protocol-dashboard/src/store/cache/music/hooks.ts index a90224c3ce9..33f433eb20e 100644 --- a/packages/protocol-dashboard/src/store/cache/music/hooks.ts +++ b/packages/protocol-dashboard/src/store/cache/music/hooks.ts @@ -10,7 +10,6 @@ import Audius from 'services/Audius' import { audiusSdk } from 'services/Audius/sdk' import AppState from 'store/types' import { Playlist, Track } from 'types' -import { getImageUrls } from 'utils/imageUrls' import { MusicError, @@ -43,7 +42,6 @@ export function fetchTopTracks(): ThunkAction< title: d.title, handle: d.user.handle, artwork: d.artwork?._480x480 ?? imageBlank, - artworkUrls: getImageUrls(d.artwork as Record), url: `${AUDIUS_URL}/tracks/${d.id}`, userUrl: `${AUDIUS_URL}/users/${d.user.id}` })) @@ -72,7 +70,6 @@ export function fetchTopPlaylists(): ThunkAction< title: d.playlistName, handle: d.user.handle, artwork: d.artwork?._480x480 ?? imageBlank, - artworkUrls: getImageUrls(d.artwork as Record), plays: d.totalPlayCount, url: `${AUDIUS_URL}/playlists/${d.id}` })) @@ -102,7 +99,6 @@ export function fetchTopAlbums(): ThunkAction< title: d.playlistName, handle: d.user.handle, artwork: d.artwork?._480x480 ?? imageBlank, - artworkUrls: getImageUrls(d.artwork as Record), plays: d.totalPlayCount, url: `${AUDIUS_URL}/playlists/${d.id}` })) diff --git a/packages/protocol-dashboard/src/store/cache/user/hooks.ts b/packages/protocol-dashboard/src/store/cache/user/hooks.ts index 74ef3939a6b..854683663ec 100644 --- a/packages/protocol-dashboard/src/store/cache/user/hooks.ts +++ b/packages/protocol-dashboard/src/store/cache/user/hooks.ts @@ -476,9 +476,6 @@ export const useUserProfile = ({ wallet }: UseUserProfile) => { ? (audiusProfile?.profilePicture?._480x480 ?? user.image) : undefined - const profilePicture = - status !== Status.Loading ? (audiusProfile?.profilePicture ?? null) : null - const dispatch: ThunkDispatch = useDispatch() useEffect(() => { if (user && !inFlight.has(wallet)) { @@ -488,12 +485,7 @@ export const useUserProfile = ({ wallet }: UseUserProfile) => { }, [dispatch, user, wallet]) if (user) { - return { - image, - profilePicture, - name: audiusProfile?.name ?? user.name, - status - } + return { image, name: audiusProfile?.name ?? user.name, status } } return {} } diff --git a/packages/protocol-dashboard/src/types.ts b/packages/protocol-dashboard/src/types.ts index e9ed71251c5..8f868574cec 100644 --- a/packages/protocol-dashboard/src/types.ts +++ b/packages/protocol-dashboard/src/types.ts @@ -251,7 +251,6 @@ export type Track = { title: string handle: string artwork: string - artworkUrls: string[] url: string userUrl: string } @@ -260,7 +259,6 @@ export type Playlist = { title: string handle: string artwork: string - artworkUrls: string[] plays: number url: string } @@ -269,7 +267,6 @@ export type Album = { title: string handle: string artwork: string - artworkUrls: string[] plays: number url: string } diff --git a/packages/protocol-dashboard/vite.config.ts b/packages/protocol-dashboard/vite.config.ts index 47ce64b4e85..ac7cb939450 100644 --- a/packages/protocol-dashboard/vite.config.ts +++ b/packages/protocol-dashboard/vite.config.ts @@ -33,7 +33,27 @@ export default defineConfig({ optimizeDeps: { esbuildOptions: { - plugins: [fixReactVirtualized] + plugins: [ + fixReactVirtualized, + { + name: 'resolve-ethers-v6-for-web3modal', + setup(build) { + // @web3modal/ethers expects ethers v6 but root node_modules has v5. + // Redirect bare 'ethers' imports from @web3modal to the local v6. + build.onResolve({ filter: /^ethers$/ }, (args) => { + if (args.importer.includes('@web3modal')) { + return { + path: path.resolve( + __dirname, + 'node_modules/ethers/lib.esm/index.js' + ) + } + } + return undefined + }) + } + } + ] } }, From 47d6e4ee68a653df9bb0a164e419d52a0fbe033d Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Sat, 4 Apr 2026 00:37:49 -0700 Subject: [PATCH 3/5] Remove write_once OAuth scope and rename types to DashboardWallet* The protocol dashboard now uses standard write scope with PKCE, so write_once is dead code. Remove it from scope validation, collapsing, and the authorize flow. Rename WriteOnceTx/WriteOnceParams to DashboardWalletTx/DashboardWalletParams and clean up type casts. Co-Authored-By: Claude Opus 4.6 --- .../pages/oauth-login-page/OAuthLoginPage.tsx | 6 +- .../components/PermissionsSection.tsx | 25 +++---- .../web/src/pages/oauth-login-page/hooks.ts | 68 ++++--------------- .../src/pages/oauth-login-page/messages.ts | 4 +- .../web/src/pages/oauth-login-page/utils.ts | 16 ++--- 5 files changed, 40 insertions(+), 79 deletions(-) diff --git a/packages/web/src/pages/oauth-login-page/OAuthLoginPage.tsx b/packages/web/src/pages/oauth-login-page/OAuthLoginPage.tsx index 7e8f6297d5d..5b40465dbe4 100644 --- a/packages/web/src/pages/oauth-login-page/OAuthLoginPage.tsx +++ b/packages/web/src/pages/oauth-login-page/OAuthLoginPage.tsx @@ -43,7 +43,7 @@ import { ContentWrapper } from './components/ContentWrapper' import { PermissionsSection } from './components/PermissionsSection' import { useOAuthSetup } from './hooks' import { messages } from './messages' -import { WriteOnceTx } from './utils' +import { DashboardWalletTx } from './utils' const { signOut } = signOutActions @@ -391,11 +391,11 @@ export const OAuthLoginPage = () => { {userAlreadyWriteAuthorized ? null : ( )} diff --git a/packages/web/src/pages/oauth-login-page/components/PermissionsSection.tsx b/packages/web/src/pages/oauth-login-page/components/PermissionsSection.tsx index 3816e03073e..dfbdd67c7ab 100644 --- a/packages/web/src/pages/oauth-login-page/components/PermissionsSection.tsx +++ b/packages/web/src/pages/oauth-login-page/components/PermissionsSection.tsx @@ -13,7 +13,7 @@ import LoadingSpinner from 'components/loading-spinner/LoadingSpinner' import styles from '../OAuthLoginPage.module.css' import { messages } from '../messages' -import { WriteOnceParams, WriteOnceTx } from '../utils' +import { DashboardWalletParams, DashboardWalletTx } from '../utils' type PermissionDetailProps = PropsWithChildren<{}> const PermissionDetail = ({ children }: PermissionDetailProps) => { @@ -26,12 +26,14 @@ const PermissionDetail = ({ children }: PermissionDetailProps) => { ) } -const getWriteOncePermissionTitle = (tx: WriteOnceTx | null) => { +const getDashboardWalletPermissionTitle = (tx: DashboardWalletTx | null) => { switch (tx) { case 'connect_dashboard_wallet': return messages.connectDashboardWalletAccess case 'disconnect_dashboard_wallet': return messages.disconnectDashboardWalletAccess + default: + return null } } @@ -44,11 +46,11 @@ export const PermissionsSection = ({ tx }: { scope: string | string[] | null - tx: WriteOnceTx | null + tx: DashboardWalletTx | null isLoggedIn: boolean isLoading: boolean userEmail: string | null - txParams?: WriteOnceParams + txParams?: DashboardWalletParams }) => { return ( @@ -60,7 +62,7 @@ export const PermissionsSection = ({ - {scope === 'write' || scope === 'write_once' ? ( + {scope === 'write' ? ( ) : ( @@ -68,18 +70,17 @@ export const PermissionsSection = ({ {scope === 'write' - ? messages.writeAccountAccess - : scope === 'write_once' - ? getWriteOncePermissionTitle(tx) - : messages.readOnlyAccountAccess} + ? (getDashboardWalletPermissionTitle(tx) ?? + messages.writeAccountAccess) + : messages.readOnlyAccountAccess} - {scope === 'write' ? ( + {scope === 'write' && !tx ? ( {messages.writeAccessGrants} ) : null} - {scope === 'write_once' ? ( + {scope === 'write' && tx && txParams ? ( - {txParams?.wallet.slice(0, 6)}...{txParams?.wallet.slice(-4)} + {txParams.wallet.slice(0, 6)}...{txParams.wallet.slice(-4)} ) : null} {scope === 'read' ? ( diff --git a/packages/web/src/pages/oauth-login-page/hooks.ts b/packages/web/src/pages/oauth-login-page/hooks.ts index a7a054800d0..fdbecb6da4c 100644 --- a/packages/web/src/pages/oauth-login-page/hooks.ts +++ b/packages/web/src/pages/oauth-login-page/hooks.ts @@ -31,9 +31,9 @@ import { handleAuthorizeConnectDashboardWallet, handleAuthorizeDisconnectDashboardWallet, isValidApiKey, - validateWriteOnceParams, - WriteOnceParams, - WriteOnceTx + validateDashboardWalletParams, + DashboardWalletParams, + DashboardWalletTx } from './utils' // Collapse space-separated OAuth scopes (e.g. 'read write') to the highest privilege. @@ -50,7 +50,6 @@ const collapseScopes = ( .flatMap((s) => (s != null ? s.split(/\s+/) : [])) .filter((t) => t.length > 0) if (tokens.includes('write')) return 'write' - if (tokens.includes('write_once')) return 'write_once' if (tokens.includes('read')) return 'read' return typeof raw === 'string' ? raw : null } @@ -115,7 +114,7 @@ const useParsedQueryParams = () => { const { error, txParams } = useMemo(() => { let error: string | null = null - let txParams: WriteOnceParams | null = null // Only used for scope=write_once + let txParams: DashboardWalletParams | null = null if (isRedirectValid === false) { error = messages.redirectURIInvalidError } else if (parsedRedirectUri === 'postmessage' && !parsedOrigin) { @@ -123,8 +122,7 @@ const useParsedQueryParams = () => { error = messages.originInvalidError } else if ( scope !== 'read' && - scope !== 'write' && - scope !== 'write_once' + scope !== 'write' ) { error = messages.scopeError } else if ( @@ -153,10 +151,10 @@ const useParsedQueryParams = () => { error = messages.invalidCodeChallengeMethodError } } - // Optional dashboard wallet tx params (write scope also supports tx param) + // Optional dashboard wallet tx params if (!error && tx) { const { error: txParamsError, txParams: txParamsRes } = - validateWriteOnceParams({ + validateDashboardWalletParams({ tx, params: rest, willUsePostMessage: parsedRedirectUri === 'postmessage' @@ -166,19 +164,6 @@ const useParsedQueryParams = () => { error = txParamsError } } - } else if (scope === 'write_once') { - // Write-once scope-specific validations: - const { error: writeOnceParamsError, txParams: txParamsRes } = - validateWriteOnceParams({ - tx, - params: rest, - willUsePostMessage: parsedRedirectUri === 'postmessage' - }) - txParams = txParamsRes - - if (writeOnceParamsError) { - error = writeOnceParamsError - } } return { txParams, error } // This is exhaustive despite what eslint thinks: @@ -256,7 +241,7 @@ export const useOAuthSetup = ({ const [queryParamsError, setQueryParamsError] = useState( initError ) - /** The fetched developer app name if write OAuth (we use `queryParamAppName` if read or writeOnce OAuth and no API key is given) */ + /** The fetched developer app name if write OAuth (we use `queryParamAppName` if read OAuth and no API key is given) */ const [registeredDeveloperAppName, setRegisteredDeveloperAppName] = useState() const appName = registeredDeveloperAppName ?? queryParamAppName @@ -547,9 +532,9 @@ export const useOAuthSetup = ({ return } - // Handle dashboard wallet tx if present with write scope + // Handle dashboard wallet tx if present if (tx && txParams) { - if ((tx as WriteOnceTx) === 'connect_dashboard_wallet') { + if (tx === 'connect_dashboard_wallet') { const success = await handleAuthorizeConnectDashboardWallet({ state, originUrl: parsedOrigin, @@ -557,15 +542,15 @@ export const useOAuthSetup = ({ onWaitForWalletSignature: onPendingTransactionApproval, onReceivedWalletSignature: onReceiveTransactionApproval, account, - txParams: txParams! + txParams }) if (!success) { return } - } else if ((tx as WriteOnceTx) === 'disconnect_dashboard_wallet') { + } else if (tx === 'disconnect_dashboard_wallet') { const success = await handleAuthorizeDisconnectDashboardWallet({ account, - txParams: txParams!, + txParams, onError }) if (!success) { @@ -573,31 +558,6 @@ export const useOAuthSetup = ({ } } } - } else if (scope === 'write_once') { - // Note: Tx = 'connect_dashboard_wallet' since that's the only option available right now for write_once scope - if ((tx as WriteOnceTx) === 'connect_dashboard_wallet') { - const success = await handleAuthorizeConnectDashboardWallet({ - state, - originUrl: parsedOrigin, - onError, - onWaitForWalletSignature: onPendingTransactionApproval, - onReceivedWalletSignature: onReceiveTransactionApproval, - account, - txParams: txParams! - }) - if (!success) { - return - } - } else if ((tx as WriteOnceTx) === 'disconnect_dashboard_wallet') { - const success = await handleAuthorizeDisconnectDashboardWallet({ - account, - txParams: txParams!, - onError - }) - if (!success) { - return - } - } } // PKCE flow: exchange for authorization code and redirect with code @@ -670,7 +630,7 @@ export const useOAuthSetup = ({ userEmail, authorize, tx, - txParams: txParams as WriteOnceParams, + txParams, display } } diff --git a/packages/web/src/pages/oauth-login-page/messages.ts b/packages/web/src/pages/oauth-login-page/messages.ts index d1683f2e9bf..030b44bd319 100644 --- a/packages/web/src/pages/oauth-login-page/messages.ts +++ b/packages/web/src/pages/oauth-login-page/messages.ts @@ -38,9 +38,9 @@ export const messages = { 'Whoops, this is an invalid link (the specified wallet is already connected to an Audius account).', disconnectWalletNotConnectedError: 'Whoops, this is an invalid link (the specified wallet is not connected to an Audius account).', - writeOnceParamsError: + txParamsError: 'Whoops, this is an invalid link (transaction params missing or invalid).', - writeOnceTxError: `Whoops, this is an invalid link ('tx' missing or invalid).`, + txError: `Whoops, this is an invalid link ('tx' missing or invalid).`, missingFieldError: 'Whoops, you must enter both your email and password.', originInvalidError: 'Whoops, this is an invalid link (redirect URI is set to `postMessage` but origin is missing).', diff --git a/packages/web/src/pages/oauth-login-page/utils.ts b/packages/web/src/pages/oauth-login-page/utils.ts index 36d89e86e35..ed2b986c1d3 100644 --- a/packages/web/src/pages/oauth-login-page/utils.ts +++ b/packages/web/src/pages/oauth-login-page/utils.ts @@ -77,7 +77,7 @@ export const formOAuthResponse = async ({ userEmail, apiKey, onError, - txSignature // Only applicable to scope = write_once + txSignature }: { account: UserMetadata userEmail?: string | null @@ -214,7 +214,7 @@ export const getIsAppAuthorized = async ({ ) return foundIndex !== undefined && foundIndex > -1 } -export type WriteOnceTx = +export type DashboardWalletTx = | 'connect_dashboard_wallet' | 'disconnect_dashboard_wallet' @@ -226,11 +226,11 @@ type DisconnectDashboardWalletParams = { wallet: string } -export type WriteOnceParams = +export type DashboardWalletParams = | ConnectDashboardWalletParams | DisconnectDashboardWalletParams -export const validateWriteOnceParams = ({ +export const validateDashboardWalletParams = ({ tx, params: rawParams, willUsePostMessage @@ -240,13 +240,13 @@ export const validateWriteOnceParams = ({ willUsePostMessage: boolean }) => { let error = null - let txParams: WriteOnceParams | null = null + let txParams: DashboardWalletParams | null = null if (tx === 'connect_dashboard_wallet') { if (!willUsePostMessage) { error = messages.connectWalletNoPostMessageError } if (!rawParams.wallet) { - error = messages.writeOnceParamsError + error = messages.txParamsError return { error, txParams } } txParams = { @@ -254,7 +254,7 @@ export const validateWriteOnceParams = ({ } } else if (tx === 'disconnect_dashboard_wallet') { if (!rawParams.wallet) { - error = messages.writeOnceParamsError + error = messages.txParamsError return { error, txParams } } txParams = { @@ -262,7 +262,7 @@ export const validateWriteOnceParams = ({ } } else { // Unknown 'tx' value - error = messages.writeOnceTxError + error = messages.txError } return { error, txParams } } From b23dc3356e8957bcb6221128e8e6b0a2b2939d0a Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Sat, 4 Apr 2026 00:42:08 -0700 Subject: [PATCH 4/5] Fix formatting in OAuth scope validation Co-Authored-By: Claude Opus 4.6 --- packages/web/src/pages/oauth-login-page/hooks.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/web/src/pages/oauth-login-page/hooks.ts b/packages/web/src/pages/oauth-login-page/hooks.ts index fdbecb6da4c..a7c94a8c88d 100644 --- a/packages/web/src/pages/oauth-login-page/hooks.ts +++ b/packages/web/src/pages/oauth-login-page/hooks.ts @@ -120,10 +120,7 @@ const useParsedQueryParams = () => { } else if (parsedRedirectUri === 'postmessage' && !parsedOrigin) { // Only applicable if redirect URI set to `postMessage` error = messages.originInvalidError - } else if ( - scope !== 'read' && - scope !== 'write' - ) { + } else if (scope !== 'read' && scope !== 'write') { error = messages.scopeError } else if ( responseMode && From d5df68f4f3f4a8950ed3a20611b05a97a050f1c7 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Sat, 4 Apr 2026 00:56:15 -0700 Subject: [PATCH 5/5] Fix lint --- packages/web/src/pages/oauth-login-page/hooks.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/web/src/pages/oauth-login-page/hooks.ts b/packages/web/src/pages/oauth-login-page/hooks.ts index a7c94a8c88d..9b2698f4664 100644 --- a/packages/web/src/pages/oauth-login-page/hooks.ts +++ b/packages/web/src/pages/oauth-login-page/hooks.ts @@ -32,8 +32,7 @@ import { handleAuthorizeDisconnectDashboardWallet, isValidApiKey, validateDashboardWalletParams, - DashboardWalletParams, - DashboardWalletTx + DashboardWalletParams } from './utils' // Collapse space-separated OAuth scopes (e.g. 'read write') to the highest privilege.