Files
zeekayandHanzo Dev 334eb9a388 auth: delete the upstream auth; Hanzo IAM is the only gate
The explorer shipped three sign-in systems. Two were Blockscout's and both
spoke to its /account/* backend, which this deployment does not run — measured,
every one of those endpoints 404s on api-explore.lux.network:

  /account/get_csrf 404    /account/send_otp 404     /account/siwe_message 404
  /account/user/info 404   /account/confirm_otp 404  /account/authenticate_via_* 404

So none of it could ever have worked. It is deleted, not re-pointed: the email
+ one-time-code modal, the SIWE wallet login, the CSRF handshake that fired on
every page render, the Dynamic.xyz provider, and the dev page that let you
paste a session cookie by hand. Rewriting any of it against our own backend
would have been building custom auth, which we do not do.

What is left is the standard OIDC client we already had, finished:

  * PKCE (S256). This client is public — it runs in a browser and can hold no
    secret — so the verifier is the only thing binding a redeemed code to the
    page that asked for it. IAM advertises S256 in its discovery document and
    we were not sending a challenge at all.
  * userinfo went to <issuer>/api/userinfo, which is a 404 and an /api/ path we
    do not serve. It is /v1/iam/oauth/userinfo, per IAM's own discovery.
  * logout now ends the IdP session (end_session_endpoint) instead of only
    dropping our cookie and leaving the user signed in upstream.

All three live in lib/oidc.ts, which already said never to rebuild these paths
at a call site.

The header's provider switch collapses to two states — signed in with IAM, or
account disabled and this is the wallet/settings menu. `rewards` is stated off:
its gate read authProvider === 'auth0', so it was already unreachable.

The ConfigMaps carried NEXT_PUBLIC_OIDC_CLIENT_ID=lux-explore-client-id, a
client that has never existed, so authorize answered 400 unknown client_id.
IAM names clients <org>-explore; all four brands now say so, and hanzo/zoo/pars
get the OIDC keys they never had.

Typecheck: no new error classes, production errors 26 -> 22.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-06 19:18:31 -07:00

229 lines
7.3 KiB
TypeScript

// OIDC user profile desktop component.
// Login redirects to the configured OIDC server (NEXT_PUBLIC_OIDC_SERVER_URL,
// e.g. https://hanzo.id for the Hanzo build); popover shows account, wallet,
// settings, and logout.
import type { ButtonProps } from '@luxfi/ui/button';
import { Button } from '@luxfi/ui/button';
import { PopoverBody, PopoverContent, PopoverRoot, PopoverTrigger } from '@luxfi/ui/popover';
import { Separator } from '@luxfi/ui/separator';
import React from 'react';
import config from 'configs/app';
import { buildLoginUrl } from 'lib/oidc';
import useWeb3AccountWithDomain from 'lib/web3/useAccountWithDomain';
import useWeb3Wallet from 'lib/web3/useWallet';
import { useDisclosure } from 'toolkit/hooks/useDisclosure';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import IconSvg from 'ui/shared/IconSvg';
import useLogout from 'ui/snippets/auth/useLogout';
import useProfileQuery from 'ui/snippets/auth/useProfileQuery';
import SettingsAddressFormat from 'ui/snippets/topBar/settings/SettingsAddressFormat';
import SettingsColorTheme from 'ui/snippets/topBar/settings/SettingsColorTheme';
import SettingsIdentIcon from 'ui/snippets/topBar/settings/SettingsIdentIcon';
import SettingsLocalTime from 'ui/snippets/topBar/settings/SettingsLocalTime';
import SettingsScamTokens from 'ui/snippets/topBar/settings/SettingsScamTokens';
const REDIRECT_URI_PATH = '/auth/callback';
const isWalletEnabled = config.features.blockchainInteraction.isEnabled;
// The identity provider's brand is the host of the configured OIDC server
// (e.g. "hanzo.id" for the Hanzo build, "lux.id" for Lux). Derive the
// sign-in label from it so the button is brand-correct on every surface
// instead of hard-coding a single brand.
function oidcProviderName(): string {
const feature = config.features.account;
if (!feature.isEnabled || feature.authProvider !== 'oidc' || !feature.oidc) {
return 'ID';
}
try {
return new URL(feature.oidc.serverUrl).host;
} catch {
return 'ID';
}
}
interface Props {
buttonSize?: ButtonProps['size'];
buttonVariant?: ButtonProps['variant'];
}
const UserProfileOidc = ({ buttonSize, buttonVariant = 'header' }: Props) => {
const profileQuery = useProfileQuery();
const profileMenu = useDisclosure();
const logout = useLogout();
const web3Wallet = useWeb3Wallet({ source: 'Header' });
const web3AccountWithDomain = useWeb3AccountWithDomain(isWalletEnabled && web3Wallet.isConnected);
const handleLoginClick = React.useCallback(async() => {
// async because the PKCE challenge is a SubtleCrypto digest.
const url = await buildLoginUrl(REDIRECT_URI_PATH);
if (url) {
window.location.href = url;
}
}, []);
const handleLogout = React.useCallback(async() => {
profileMenu.onClose();
await logout();
}, [ logout, profileMenu ]);
const handleOpenChange = React.useCallback(({ open }: { open: boolean }) => {
if (open) {
profileMenu.onOpen();
} else {
profileMenu.onClose();
}
}, [ profileMenu ]);
const handleConnectWallet = React.useCallback(() => {
profileMenu.onClose();
web3Wallet.openModal();
}, [ profileMenu, web3Wallet ]);
const handleDisconnectWallet = React.useCallback(() => {
web3Wallet.disconnect();
}, [ web3Wallet ]);
const isLoading = profileQuery.isLoading;
const data = profileQuery.data;
const walletAddress = isWalletEnabled ? web3AccountWithDomain.address : undefined;
const walletDomain = isWalletEnabled ? web3AccountWithDomain.domain : undefined;
const buttonLabel = (() => {
if (isLoading) {
return null;
}
if (data) {
const handle = data.nickname || (data.email ? data.email.split('@')[0] : 'Account');
return (
<div className="flex items-center gap-1.5">
<IconSvg name="profile" className="w-4 h-4"/>
<span>{ handle }</span>
</div>
);
}
return (
<span>Sign in</span>
);
})();
return (
<PopoverRoot
positioning={{ placement: 'bottom-end' }}
lazyMount={ false }
open={ profileMenu.open }
onOpenChange={ handleOpenChange }
>
<PopoverTrigger>
<Button
size={ buttonSize }
variant={ data ? buttonVariant : undefined }
loading={ isLoading }
selected={ Boolean(data) }
className={ data ?
`px-2.5 lg:px-3 font-bold` :
'bg-white text-black font-medium px-4 py-1.5 rounded-md text-sm hover:bg-gray-200 transition-colors'
}
>
{ buttonLabel }
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px]">
<PopoverBody>
{ /* Account section — shown when logged in */ }
{ data && (
<>
<div>
Account
</div>
<div>
{ data.nickname || data.name || (data.email ? data.email.split('@')[0] : 'User') }
</div>
{ data.email && (
<div>
{ data.email }
</div>
) }
<Separator className="my-3"/>
</>
) }
{ /* Sign in section — shown when not logged in */ }
{ !data && !isLoading && (
<>
<Button
size="sm"
className="w-full font-semibold"
onClick={ handleLoginClick }
variant="solid"
>
Sign in with { oidcProviderName() }
</Button>
<Separator className="my-3"/>
</>
) }
{ /* Wallet section */ }
{ isWalletEnabled && walletAddress && (
<>
<div>
Wallet
</div>
<div>
<AddressEntity
address={{ hash: walletAddress, ens_domain_name: walletDomain }}
truncation="dynamic"
/>
</div>
<Button size="sm" className="w-full" variant="outline" onClick={ handleDisconnectWallet }>
Disconnect wallet
</Button>
<Separator className="my-3"/>
</>
) }
{ isWalletEnabled && !walletAddress && (
<>
<Button size="sm" className="w-full" variant="outline" onClick={ handleConnectWallet }>
Connect wallet
</Button>
<Separator className="my-3"/>
</>
) }
{ /* Settings section */ }
<div>
Settings
</div>
<SettingsColorTheme onSelect={ profileMenu.onClose }/>
<Separator className="my-3"/>
<SettingsIdentIcon/>
<SettingsAddressFormat/>
<Separator className="my-3"/>
<div>
<SettingsScamTokens/>
<SettingsLocalTime/>
</div>
{ /* Sign out */ }
{ data && (
<>
<Separator className="my-3"/>
<Button
size="sm"
className="w-full"
variant="outline_danger"
onClick={ handleLogout }
>
Sign out
</Button>
</>
) }
</PopoverBody>
</PopoverContent>
</PopoverRoot>
);
};
export default React.memo(UserProfileOidc);