mirror of
https://github.com/luxfi/explore.git
synced 2026-09-24 00:28:18 +00:00
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:
+3
-2
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user