feat: OIDC login via lux.id, full-width layout, P-chain stats fix

- Add Sign in button to TopBar with OIDC flow to lux.id
- Merge settings (color theme, identicon, address format) into user menu popover
- Remove duplicate user profile from HeroBanner
- Full-width stacked blocks/transactions layout on homepage
- Fix P-chain stats: CSP connect-src allows base origin for SDK calls
- Fix connected count: infer from uptime when field missing
- Fix dark mode popover background
- Enable OIDC in .env.production (lux.id, client: explore)
This commit is contained in:
2026-03-01 20:34:00 -08:00
parent 6d65248101
commit 284d506593
10 changed files with 176 additions and 52 deletions
+3 -2
View File
@@ -16,6 +16,7 @@ NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=wss
NEXT_PUBLIC_APP_HOST=explore.lux.network
NEXT_PUBLIC_APP_PROTOCOL=https
NEXT_PUBLIC_IS_TESTNET=false
NEXT_PUBLIC_NETWORK_RPC_URL=https://api.lux.network/ext/bc/C/rpc
# Branding
NEXT_PUBLIC_NETWORK_LOGO=/logos/lux-logo-white.png
@@ -35,8 +36,8 @@ NEXT_PUBLIC_COLOR_THEME_DEFAULT=dark
NEXT_PUBLIC_HOMEPAGE_CHARTS=["daily_txs", "coin_price", "market_cap"]
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_ACCOUNT_AUTH_PROVIDER=oidc
NEXT_PUBLIC_OIDC_SERVER_URL=https://hanzo.id
NEXT_PUBLIC_OIDC_CLIENT_ID=lux-explore-client-id
NEXT_PUBLIC_OIDC_SERVER_URL=https://lux.id
NEXT_PUBLIC_OIDC_CLIENT_ID=explore
# Stats
NEXT_PUBLIC_STATS_API_HOST=https://api-explore.lux.network/api/stats
+3 -1
View File
@@ -28,7 +28,9 @@ function computeValidatorStats(
for (const v of validators) {
totalStake += BigInt(v.stakeAmount ?? v.weight);
if (v.connected) {
// connected field may be absent in some node configurations;
// infer connectivity from uptime > 0 when missing
if (v.connected ?? (parseFloat(v.uptime) > 0)) {
connectedCount += 1;
}
+9 -1
View File
@@ -41,9 +41,17 @@ export function app(isPrivateMode = false): CspDev.DirectiveDescriptor {
...Object.values(config.apis).filter(Boolean).map((api) => api.endpoint),
...Object.values(config.apis).filter(Boolean).map((api) => api.socketEndpoint),
// chain RPC server
// chain RPC server (full URLs + base origins for SDK calls to other paths like /ext/bc/P)
...config.chain.rpcUrls,
...config.chain.rpcUrls.map((url) => {
try {
return new URL(url).origin;
} catch {
return '';
}
}),
'https://infragrid.v.network', // RPC providers
'https://lux.id', // OIDC authentication provider
// github (spec for api-docs page)
'raw.githubusercontent.com',
+2 -4
View File
@@ -9,7 +9,6 @@ import RewardsButton from 'ui/rewards/RewardsButton';
import AdBanner from 'ui/shared/ad/AdBanner';
import SearchBar from 'ui/snippets/searchBar/SearchBarDesktop';
import SearchBarMobile from 'ui/snippets/searchBar/SearchBarMobile';
import UserProfileDesktop from 'ui/snippets/user/UserProfileDesktop';
export const BACKGROUND_DEFAULT = 'var(--chakra-colors-whiteAlpha-50)';
const TEXT_COLOR_DEFAULT = 'white';
@@ -73,10 +72,9 @@ const HeroBanner = () => {
`${ config.chain.name } explorer`
}
</Heading>
{ config.UI.navigation.layout === 'vertical' && (
{ config.UI.navigation.layout === 'vertical' && config.features.rewards.isEnabled && (
<Box display={{ base: 'none', lg: 'flex' }} gap={ 2 }>
{ config.features.rewards.isEnabled && <RewardsButton variant="hero"/> }
<UserProfileDesktop buttonVariant="hero"/>
<RewardsButton variant="hero"/>
</Box>
) }
</Flex>
+2 -2
View File
@@ -30,9 +30,9 @@ const LatestBlocks = () => {
// const blocksMaxCount = isMobile ? 2 : 3;
let blocksMaxCount: number;
if (config.features.rollup.isEnabled || config.UI.views.block.hiddenFields?.total_reward) {
blocksMaxCount = isMobile ? 4 : 5;
blocksMaxCount = isMobile ? 4 : 8;
} else {
blocksMaxCount = isMobile ? 2 : 3;
blocksMaxCount = isMobile ? 4 : 6;
}
const { data, isPlaceholderData, isError } = useApiQuery('general:homepage_blocks', {
queryOptions: {
+9 -9
View File
@@ -305,15 +305,15 @@ const NetworkOverview = () => {
<Stats/>
</Box>
{ /* ── Two-column: Latest blocks (left) + Latest transactions (right) ── */ }
<Flex mt={ 8 } direction={{ base: 'column', lg: 'row' }} columnGap={ 12 } rowGap={ 6 }>
<Box flexGrow={ 1 } flexBasis="50%" overflow="hidden">
<LatestBlocks/>
</Box>
<Box flexGrow={ 1 } flexBasis="50%" overflow="hidden">
<Transactions/>
</Box>
</Flex>
{ /* ── Latest blocks (full-width horizontal scroll) ── */ }
<Box mt={ 8 }>
<LatestBlocks/>
</Box>
{ /* ── Latest transactions (full-width below blocks) ── */ }
<Box mt={ 6 }>
<Transactions/>
</Box>
{ /* ── Chain Health section (below blocks/txns) ── */ }
<Grid
@@ -60,7 +60,7 @@ const NavigationDesktop = () => {
) }
</Flex>
</chakra.nav>
<Flex gap={ 2 } ml={ 8 } _empty={{ display: 'none' }}>
<Flex gap={ 2 } ml={ 8 }>
<NavigationPromoBanner/>
{ config.features.rewards.isEnabled && <RewardsButton size="sm"/> }
<UserProfileDesktop buttonSize="sm"/>
+6
View File
@@ -9,6 +9,7 @@ import { Link } from 'toolkit/chakra/link';
import { MenuContent, MenuItem, MenuRoot, MenuTrigger } from 'toolkit/chakra/menu';
import { CONTENT_MAX_WIDTH } from 'ui/shared/layout/utils';
import SearchBar from 'ui/snippets/searchBar/SearchBarDesktop';
import UserProfileDesktop from 'ui/snippets/user/UserProfileDesktop';
import ChainSwitcher from './ChainSwitcher';
import NetworkSelector from './NetworkSelector';
@@ -212,6 +213,11 @@ const TopBar = () => {
{ /* ── Spacer ── */ }
<Box flex={ 1 } display={{ base: 'block', lg: 'none' }}/>
{ /* ── User profile / Sign in ── */ }
<Box flexShrink={ 0 }>
<UserProfileDesktop buttonSize="sm"/>
</Box>
</Flex>
</Box>
);
@@ -1,22 +1,30 @@
// OIDC-specific user profile desktop component.
// Login redirects to lux.id; shows name/email + logout when authenticated.
// OIDC user profile desktop component.
// Login redirects to lux.id; popover shows account, wallet, settings, and logout.
import { Box, Flex } from '@chakra-ui/react';
import { Box, Flex, HStack, VStack } from '@chakra-ui/react';
import type { ButtonProps } from '@chakra-ui/react';
import React from 'react';
import config from 'configs/app';
import shortenString from 'lib/shortenString';
import useWeb3AccountWithDomain from 'lib/web3/useAccountWithDomain';
import useWeb3Wallet from 'lib/web3/useWallet';
import { Button } from 'toolkit/chakra/button';
import { Link } from 'toolkit/chakra/link';
import { PopoverBody, PopoverContent, PopoverRoot, PopoverTrigger } from 'toolkit/chakra/popover';
import { Separator } from 'toolkit/chakra/separator';
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;
function buildOidcLoginUrl(): string {
const feature = config.features.account;
@@ -49,56 +57,74 @@ const UserProfileOidc = ({ buttonSize, buttonVariant = 'header' }: Props) => {
const profileMenu = useDisclosure();
const logout = useLogout();
const handleButtonClick = React.useCallback(() => {
if (profileQuery.data) {
profileMenu.onOpen();
return;
}
const web3Wallet = useWeb3Wallet({ source: 'Header' });
const web3AccountWithDomain = useWeb3AccountWithDomain(isWalletEnabled && web3Wallet.isConnected);
const handleLoginClick = React.useCallback(() => {
const url = buildOidcLoginUrl();
if (url) {
window.location.href = url;
}
}, [ profileQuery.data, profileMenu ]);
}, []);
const handleLogout = React.useCallback(async() => {
profileMenu.onClose();
await logout();
}, [ logout, profileMenu ]);
const handleMenuOpenChange = React.useCallback(({ open }: { open: boolean }) => {
!open && profileMenu.onOpenChange({ open });
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.email ? data.email.split('@')[0] : 'My account';
const handle = data.nickname || (data.email ? data.email.split('@')[0] : 'Account');
return (
<Flex align="center" gap={ 2 }>
<HStack gap={ 2 }>
<IconSvg name="profile" boxSize={ 5 }/>
<Box display={{ base: 'none', md: 'block' }}>{ handle }</Box>
</Flex>
</HStack>
);
}
return 'Log in';
return (
<HStack gap={ 2 }>
<IconSvg name="profile" boxSize={ 5 }/>
<Box display={{ base: 'none', md: 'block' }}>Sign in</Box>
</HStack>
);
})();
return (
<PopoverRoot
positioning={{ placement: 'bottom-end' }}
lazyMount={ false }
open={ profileMenu.open }
onOpenChange={ handleMenuOpenChange }
onOpenChange={ handleOpenChange }
>
<PopoverTrigger>
<Button
size={ buttonSize }
variant={ buttonVariant }
onClick={ handleButtonClick }
loading={ isLoading }
selected={ Boolean(data) }
px={{ base: 2.5, lg: 3 }}
@@ -107,24 +133,107 @@ const UserProfileOidc = ({ buttonSize, buttonVariant = 'header' }: Props) => {
{ buttonLabel }
</Button>
</PopoverTrigger>
{ data && profileMenu.open && (
<PopoverContent w="220px">
<PopoverBody>
<Flex direction="column" gap={ 2 } py={ 1 }>
<Box fontSize="sm" color="text.secondary" px={ 1 }>
{ data.email ?? shortenString(String(data.address_hash ?? '')) }
<PopoverContent w="280px" bg={{ _light: 'white', _dark: 'gray.900' }}>
<PopoverBody>
{ /* Account section — shown when logged in */ }
{ data && (
<>
<Box fontSize="xs" fontWeight={ 600 } color="text.secondary" textTransform="uppercase" letterSpacing="wider" mb={ 2 }>
Account
</Box>
<Separator/>
<Link href="/auth/profile" textStyle="sm" color="text.primary" px={ 1 }>
<Box fontSize="sm" fontWeight={ 600 } mb={ 1 }>
{ data.nickname || data.name || (data.email ? data.email.split('@')[0] : 'User') }
</Box>
{ data.email && (
<Box fontSize="xs" color="text.secondary" mb={ 2 }>
{ data.email }
</Box>
) }
<Link href="/auth/profile" display="block" textStyle="sm" px={ 0 } py={ 1 } color="text.primary" _hover={{ color: 'link.primary.hover' }}>
My profile
</Link>
<Button variant="ghost" size="sm" onClick={ handleLogout } colorPalette="red" justifyContent="flex-start" px={ 1 }>
<Separator my={ 3 }/>
</>
) }
{ /* Sign in section — shown when not logged in */ }
{ !data && !isLoading && (
<>
<Button
size="sm"
width="full"
onClick={ handleLoginClick }
variant="outline"
fontWeight={ 600 }
>
Sign in with Lux ID
</Button>
<Separator my={ 3 }/>
</>
) }
{ /* Wallet section */ }
{ isWalletEnabled && walletAddress && (
<>
<Box fontSize="xs" fontWeight={ 600 } color="text.secondary" textTransform="uppercase" letterSpacing="wider" mb={ 2 }>
Wallet
</Box>
<Flex alignItems="center" columnGap={ 2 } justifyContent="space-between" mb={ 2 }>
<AddressEntity
address={{ hash: walletAddress, ens_domain_name: walletDomain }}
truncation="dynamic"
fontSize="sm"
fontWeight={ 700 }
/>
</Flex>
<Button size="sm" width="full" variant="outline" onClick={ handleDisconnectWallet }>
Disconnect wallet
</Button>
<Separator my={ 3 }/>
</>
) }
{ isWalletEnabled && !walletAddress && (
<>
<Button size="sm" width="full" variant="outline" onClick={ handleConnectWallet }>
Connect wallet
</Button>
<Separator my={ 3 }/>
</>
) }
{ /* Settings section */ }
<Box fontSize="xs" fontWeight={ 600 } color="text.secondary" textTransform="uppercase" letterSpacing="wider" mb={ 2 }>
Settings
</Box>
<SettingsColorTheme onSelect={ profileMenu.onClose }/>
<Separator my={ 3 }/>
<SettingsIdentIcon/>
<SettingsAddressFormat/>
<Separator my={ 3 }/>
<VStack gap={ 1 }>
<SettingsScamTokens/>
<SettingsLocalTime/>
</VStack>
{ /* Sign out */ }
{ data && (
<>
<Separator my={ 3 }/>
<Button
size="sm"
width="full"
variant="outline"
onClick={ handleLogout }
color="red.400"
borderColor="red.400"
_hover={{ bg: 'red.400', color: 'white' }}
>
Sign out
</Button>
</Flex>
</PopoverBody>
</PopoverContent>
) }
</>
) }
</PopoverBody>
</PopoverContent>
</PopoverRoot>
);
};
@@ -60,7 +60,7 @@ const UserWalletDesktop = ({ buttonSize, buttonVariant = 'header' }: Props) => {
isAutoConnectDisabled={ isAutoConnectDisabled }
/>
</PopoverTrigger>
<PopoverContent w="280px">
<PopoverContent w="280px" bg={{ _light: 'white', _dark: 'gray.900' }}>
<PopoverBody>
<UserWalletMenuContent
address={ isWalletEnabled ? web3AccountWithDomain.address : undefined }