mirror of
https://github.com/luxfi/explore.git
synced 2026-07-27 03:13:50 +00:00
unify all sql images to :latest (PG18)
Single version policy: ghcr.io/hanzoai/sql:latest everywhere. No version pinning, no backwards compat, always latest stable.
This commit is contained in:
+1
-1
@@ -12,7 +12,7 @@ services:
|
||||
- explorer-network
|
||||
|
||||
postgres:
|
||||
image: postgres:14
|
||||
image: ghcr.io/hanzoai/sql:latest
|
||||
container_name: lux-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
||||
@@ -9,7 +9,7 @@ export const accountSchema = yup
|
||||
.string<AuthProvider>()
|
||||
.when('NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED', {
|
||||
is: (value: boolean) => value === true,
|
||||
then: (schema) => schema.oneOf([ 'auth0', 'dynamic' ]),
|
||||
then: (schema) => schema.oneOf([ 'auth0', 'dynamic', 'oidc' ]),
|
||||
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_ACCOUNT_AUTH_PROVIDER cannot not be used if NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED is not defined'),
|
||||
}),
|
||||
NEXT_PUBLIC_ACCOUNT_DYNAMIC_ENVIRONMENT_ID: yup
|
||||
@@ -19,4 +19,19 @@ export const accountSchema = yup
|
||||
then: (schema) => schema.required(),
|
||||
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_ACCOUNT_DYNAMIC_ENVIRONMENT_ID can only be used if NEXT_PUBLIC_ACCOUNT_AUTH_PROVIDER is set to \'dynamic\' '),
|
||||
}),
|
||||
NEXT_PUBLIC_OIDC_SERVER_URL: yup
|
||||
.string()
|
||||
.url()
|
||||
.when('NEXT_PUBLIC_ACCOUNT_AUTH_PROVIDER', {
|
||||
is: (value: AuthProvider) => value === 'oidc',
|
||||
then: (schema) => schema.required(),
|
||||
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_OIDC_SERVER_URL can only be used if NEXT_PUBLIC_ACCOUNT_AUTH_PROVIDER is set to \'oidc\''),
|
||||
}),
|
||||
NEXT_PUBLIC_OIDC_CLIENT_ID: yup
|
||||
.string()
|
||||
.when('NEXT_PUBLIC_ACCOUNT_AUTH_PROVIDER', {
|
||||
is: (value: AuthProvider) => value === 'oidc',
|
||||
then: (schema) => schema.required(),
|
||||
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_OIDC_CLIENT_ID can only be used if NEXT_PUBLIC_ACCOUNT_AUTH_PROVIDER is set to \'oidc\''),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
# Chakra UI v3 → @hanzo/ui Migration Strategy
|
||||
|
||||
## Scale
|
||||
|
||||
| Category | Count |
|
||||
|----------|-------|
|
||||
| Files importing `@chakra-ui/react` directly | ~870 |
|
||||
| Files consuming `toolkit/chakra/` wrappers | ~822 |
|
||||
| Chakra wrapper files in `toolkit/chakra/` | 38 |
|
||||
| Style-as-props usages (`w=`, `h=`, `bg=`, `p=`, etc.) | ~4,600 |
|
||||
| Direct `Box`/`Flex`/`Text` usages (not via toolkit) | ~911 |
|
||||
| `chakra.element` factory usages | ~470 (140 files) |
|
||||
|
||||
**Key insight:** 94% of component usage flows through `toolkit/chakra/` wrappers. Swapping wrapper internals is transparent to consumers.
|
||||
|
||||
## Fundamental Challenge
|
||||
|
||||
Chakra v3 = runtime CSS-in-JS + style-as-props
|
||||
`@hanzo/ui` v5 = Tailwind + CVA + Radix primitives
|
||||
|
||||
These are **not** drop-in compatible. The ~4,600 style-as-props usages (`<Box p={4} bg="gray.100">`) have no direct `@hanzo/ui` equivalent.
|
||||
|
||||
## Recommended Strategy: Shim Approach
|
||||
|
||||
Create lightweight shim components (`Box`, `Flex`, `Text`) that accept Chakra-style props but render plain HTML with inline styles. This eliminates the Chakra/Emotion runtime (~45kB gzipped) without requiring a full Tailwind conversion.
|
||||
|
||||
## Phase Plan
|
||||
|
||||
### Phase 1 — Add @hanzo/ui (2–3 days)
|
||||
- Install `@hanzo/ui` alongside Chakra
|
||||
- Verify no peer-dependency conflicts
|
||||
- Use `@hanzo/ui` for **new** components only
|
||||
- No breaking changes; both libraries coexist
|
||||
|
||||
### Phase 2 — Swap toolkit/chakra/ internals (5–7 days)
|
||||
- For each of the 38 wrapper files in `toolkit/chakra/`:
|
||||
- Identify the `@hanzo/ui` equivalent (Button, Dialog, Table, etc.)
|
||||
- Swap the internal implementation
|
||||
- Keep the exported API identical (prop names, behavior)
|
||||
- Consumers see zero changes
|
||||
- **Testing:** existing Playwright/Vitest suite must pass after each file
|
||||
|
||||
### Phase 3 — Primitive shims (3–5 days)
|
||||
Create `toolkit/shims/`:
|
||||
```tsx
|
||||
// Box.tsx — accepts Chakra props, renders div with inline styles
|
||||
// Flex.tsx — Box with display:flex
|
||||
// Text.tsx — renders span/p with mapped typography props
|
||||
// Grid.tsx, Stack.tsx, etc.
|
||||
```
|
||||
- Replace direct `@chakra-ui/react` imports of primitives with shims
|
||||
- Style-as-props mapped to inline CSS via prop → CSS property table
|
||||
- Run `grep -r "from '@chakra-ui/react'" --include="*.tsx"` to track progress
|
||||
|
||||
### Phase 4 — Complex components (10–14 days)
|
||||
Migrate in order of usage frequency:
|
||||
1. Modal → `@hanzo/ui` Dialog
|
||||
2. Tabs → `@hanzo/ui` Tabs
|
||||
3. Table → `@hanzo/ui` Table
|
||||
4. Tooltip → `@hanzo/ui` Tooltip
|
||||
5. Input/Form — already wrapped in `toolkit/chakra/input.tsx`
|
||||
6. Menu → `@hanzo/ui` DropdownMenu (Radix-based)
|
||||
|
||||
### Phase 5 — Remove Chakra (2–3 days)
|
||||
- Remove `@chakra-ui/react`, `@emotion/react`, `@emotion/styled` from `package.json`
|
||||
- Remove `ChakraProvider` from `pages/_app.tsx`
|
||||
- Clean up any remaining `chakra.*` factory calls
|
||||
- Verify build passes with `pnpm build`
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|-----------|
|
||||
| Color mode flicker after removing ChakraProvider | Already using `next-themes` — color mode survives |
|
||||
| Style-as-props with complex Chakra tokens (`colorScheme`, `size`) | Map tokens in shim layer |
|
||||
| SSR hydration mismatches | Inline styles are SSR-safe; no CSS-in-JS hydration |
|
||||
| Breaking Playwright tests | Run full suite after each phase before merging |
|
||||
| Third-party components using Chakra internally | Use `@chakra-ui/react` as a peer for those; don't remove globally until all are replaced |
|
||||
|
||||
## Estimated Effort
|
||||
|
||||
| Phase | Effort |
|
||||
|-------|--------|
|
||||
| Phase 1: Add @hanzo/ui | 2–3 days |
|
||||
| Phase 2: toolkit/chakra/ internals | 5–7 days |
|
||||
| Phase 3: Primitive shims | 3–5 days |
|
||||
| Phase 4: Complex components | 10–14 days |
|
||||
| Phase 5: Remove Chakra | 2–3 days |
|
||||
| **Total** | **22–32 days** |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `toolkit/theme/foundations/semanticTokens.ts` token system maps 1:1 to CSS custom properties — these survive unchanged as they're already framework-agnostic
|
||||
- `next-themes` is used for dark/light mode (not Chakra's built-in) — no migration needed here
|
||||
- `toolkit/chakra/` is the single choke point; migrating it in Phase 2 buys the most leverage with least risk
|
||||
- Start with the most-used wrappers: `Button`, `Box`, `Text`, `Flex`, `Input`, `Modal`
|
||||
|
||||
## Decision: Start Date
|
||||
|
||||
Phase 1 can begin immediately. Recommended: start after current sprint's feature work is stable in production.
|
||||
@@ -4,6 +4,7 @@ export { getPChain, getInfo, getHealth } from './client';
|
||||
export { useCurrentValidators } from './useCurrentValidators';
|
||||
export { useBlockchains } from './useBlockchains';
|
||||
export { useSubnets } from './useSubnets';
|
||||
export { useChainHeights } from './useChainHeights';
|
||||
|
||||
export type {
|
||||
PChainValidator,
|
||||
@@ -18,3 +19,4 @@ export type {
|
||||
} from './types';
|
||||
|
||||
export type { UseCurrentValidatorsResult } from './useCurrentValidators';
|
||||
export type { UseChainHeightsResult } from './useChainHeights';
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// React Query hook for fetching live block heights for P-chain and C-chain.
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { getEnvValue } from 'configs/app/utils';
|
||||
|
||||
const HEIGHTS_STALE_TIME_MS = 15_000;
|
||||
const HEIGHTS_QUERY_KEY = 'pchain:chainHeights' as const;
|
||||
const CCHAIN_RPC_PATTERN = /\/ext\/bc\/C\/rpc$/;
|
||||
|
||||
function getApiBase(): string {
|
||||
const rpcUrl = getEnvValue('NEXT_PUBLIC_NETWORK_RPC_URL') ?? '';
|
||||
return rpcUrl.replace(CCHAIN_RPC_PATTERN, '');
|
||||
}
|
||||
|
||||
async function fetchChainHeights(): Promise<{ pChain: number; cChain: number }> {
|
||||
const base = getApiBase();
|
||||
|
||||
const [ pRes, cRes ] = await Promise.allSettled([
|
||||
fetch(`${ base }/ext/bc/P`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', method: 'platform.getHeight', params: {}, id: 1 }),
|
||||
}),
|
||||
fetch(`${ base }/ext/bc/C/rpc`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', method: 'eth_blockNumber', params: [], id: 2 }),
|
||||
}),
|
||||
]);
|
||||
|
||||
let pChain = 0;
|
||||
let cChain = 0;
|
||||
|
||||
if (pRes.status === 'fulfilled' && pRes.value.ok) {
|
||||
const data = await pRes.value.json() as { result?: { height?: string } };
|
||||
pChain = data.result?.height ? parseInt(data.result.height, 10) : 0;
|
||||
}
|
||||
|
||||
if (cRes.status === 'fulfilled' && cRes.value.ok) {
|
||||
const data = await cRes.value.json() as { result?: string };
|
||||
cChain = data.result ? parseInt(data.result, 16) : 0;
|
||||
}
|
||||
|
||||
return { pChain, cChain };
|
||||
}
|
||||
|
||||
export interface UseChainHeightsResult {
|
||||
readonly pChainHeight: number;
|
||||
readonly cChainHeight: number;
|
||||
readonly isLoading: boolean;
|
||||
}
|
||||
|
||||
export function useChainHeights(): UseChainHeightsResult {
|
||||
const query = useQuery({
|
||||
queryKey: [ HEIGHTS_QUERY_KEY ],
|
||||
queryFn: fetchChainHeights,
|
||||
staleTime: HEIGHTS_STALE_TIME_MS,
|
||||
refetchInterval: HEIGHTS_STALE_TIME_MS,
|
||||
});
|
||||
|
||||
return {
|
||||
pChainHeight: query.data?.pChain ?? 0,
|
||||
cChainHeight: query.data?.cChain ?? 0,
|
||||
isLoading: query.isLoading,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Box, Flex, Grid, Text } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
import { useBlockchains, useCurrentValidators } from 'lib/api/pchain';
|
||||
import { useBlockchains, useChainHeights, useCurrentValidators } from 'lib/api/pchain';
|
||||
import type { PChainBlockchain } from 'lib/api/pchain';
|
||||
import useIsMobile from 'lib/hooks/useIsMobile';
|
||||
import { Heading } from 'toolkit/chakra/heading';
|
||||
@@ -80,9 +80,11 @@ interface ChainRowProps {
|
||||
readonly vm: string;
|
||||
readonly href: string | undefined;
|
||||
readonly tier?: string;
|
||||
readonly height?: number;
|
||||
readonly heightLoading?: boolean;
|
||||
}
|
||||
|
||||
const ChainRow = ({ name, fullName, vm, href, tier }: ChainRowProps) => {
|
||||
const ChainRow = ({ name, fullName, vm, href, tier, height, heightLoading }: ChainRowProps) => {
|
||||
const content = (
|
||||
<Flex
|
||||
align="center"
|
||||
@@ -103,6 +105,13 @@ const ChainRow = ({ name, fullName, vm, href, tier }: ChainRowProps) => {
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex align="center" gap={ 1.5 }>
|
||||
{ height !== undefined && (
|
||||
<Skeleton loading={ heightLoading }>
|
||||
<Text fontSize="xs" color="text.secondary" fontFamily="mono">
|
||||
{ height > 0 ? `#${ height.toLocaleString() }` : '' }
|
||||
</Text>
|
||||
</Skeleton>
|
||||
) }
|
||||
{ tier && <Tag size="sm" variant="subtle">{ tier }</Tag> }
|
||||
<Tag size="sm" variant="subtle">{ vm }</Tag>
|
||||
</Flex>
|
||||
@@ -200,6 +209,7 @@ const NetworkOverview = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const { stats, isLoading: validatorsLoading } = useCurrentValidators();
|
||||
const { blockchains, isLoading: chainsLoading } = useBlockchains();
|
||||
const { pChainHeight, cChainHeight, isLoading: heightsLoading } = useChainHeights();
|
||||
|
||||
const l1Chains = React.useMemo(
|
||||
() => blockchains.filter((c) => c.subnetID !== PRIMARY_NETWORK_ID),
|
||||
@@ -228,6 +238,10 @@ const NetworkOverview = () => {
|
||||
flexWrap="wrap"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Metric label="C-Chain" value={ cChainHeight > 0 ? `#${ cChainHeight.toLocaleString() }` : '—' } isLoading={ heightsLoading }/>
|
||||
<Box w="1px" h="28px" bgColor="border.divider" display={{ base: 'none', md: 'block' }}/>
|
||||
<Metric label="P-Chain" value={ pChainHeight > 0 ? `#${ pChainHeight.toLocaleString() }` : '—' } isLoading={ heightsLoading }/>
|
||||
<Box w="1px" h="28px" bgColor="border.divider" display={{ base: 'none', md: 'block' }}/>
|
||||
<Metric label="Chains" value={ String(totalChains) } isLoading={ isLoading }/>
|
||||
<Box w="1px" h="28px" bgColor="border.divider" display={{ base: 'none', md: 'block' }}/>
|
||||
<Metric label="Validators" value={ String(stats.validatorCount) } isLoading={ isLoading }/>
|
||||
@@ -276,6 +290,8 @@ const NetworkOverview = () => {
|
||||
fullName={ chain.fullName }
|
||||
vm={ chain.vm }
|
||||
href={ chain.href }
|
||||
height={ chain.id === 'C' ? cChainHeight : chain.id === 'P' ? pChainHeight : undefined }
|
||||
heightLoading={ heightsLoading }
|
||||
/>
|
||||
)) }
|
||||
</Flex>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useIsSticky } from 'toolkit/hooks/useIsSticky';
|
||||
import RewardsButton from 'ui/rewards/RewardsButton';
|
||||
import NetworkIcon from 'ui/snippets/networkLogo/NetworkIcon';
|
||||
import UserProfileAuth0 from 'ui/snippets/user/profile/auth0/UserProfileMobile';
|
||||
import UserProfileOidc from 'ui/snippets/user/profile/oidc/UserProfileDesktop';
|
||||
import UserWalletMobile from 'ui/snippets/user/wallet/UserWalletMobile';
|
||||
|
||||
import RollupStageBadge from '../navigation/RollupStageBadge';
|
||||
@@ -33,6 +34,8 @@ const HeaderMobile = ({ hideSearchButton, onGoToSearchResults }: Props) => {
|
||||
return <UserProfileAuth0/>;
|
||||
case 'dynamic':
|
||||
return <UserProfileDynamic/>;
|
||||
case 'oidc':
|
||||
return <UserProfileOidc/>;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import dynamic from 'next/dynamic';
|
||||
|
||||
import config from 'configs/app';
|
||||
import UserProfileAuth0 from 'ui/snippets/user/profile/auth0/UserProfileDesktop';
|
||||
import UserProfileOidc from 'ui/snippets/user/profile/oidc/UserProfileDesktop';
|
||||
import UserWalletDesktop from 'ui/snippets/user/wallet/UserWalletDesktop';
|
||||
|
||||
const UserProfileDynamic = dynamic(() => import('ui/snippets/user/profile/dynamic/UserProfile'), { ssr: false });
|
||||
@@ -20,8 +21,8 @@ const UserProfileDesktop = ({ buttonSize, buttonVariant = 'header' }: Props) =>
|
||||
return <UserProfileAuth0 buttonSize={ buttonSize } buttonVariant={ buttonVariant }/>;
|
||||
case 'dynamic':
|
||||
return <UserProfileDynamic buttonSize={ buttonSize } buttonVariant={ buttonVariant }/>;
|
||||
default:
|
||||
break;
|
||||
case 'oidc':
|
||||
return <UserProfileOidc buttonSize={ buttonSize } buttonVariant={ buttonVariant }/>;
|
||||
}
|
||||
}
|
||||
// Always render the wallet/settings menu — it handles both wallet and settings
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// OIDC-specific user profile desktop component.
|
||||
// Login redirects to lux.id; shows name/email + logout when authenticated.
|
||||
|
||||
import { Box, Flex } 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 { Button } from 'toolkit/chakra/button';
|
||||
import { PopoverBody, PopoverContent, PopoverRoot, PopoverTrigger } from 'toolkit/chakra/popover';
|
||||
import { Separator } from 'toolkit/chakra/separator';
|
||||
import { Link } from 'toolkit/chakra/link';
|
||||
import { useDisclosure } from 'toolkit/hooks/useDisclosure';
|
||||
import IconSvg from 'ui/shared/IconSvg';
|
||||
import useLogout from 'ui/snippets/auth/useLogout';
|
||||
import useProfileQuery from 'ui/snippets/auth/useProfileQuery';
|
||||
|
||||
const REDIRECT_URI_PATH = '/auth/callback';
|
||||
|
||||
function buildOidcLoginUrl(): string {
|
||||
const feature = config.features.account;
|
||||
if (!feature.isEnabled || feature.authProvider !== 'oidc' || !feature.oidc) {
|
||||
return '';
|
||||
}
|
||||
const { serverUrl, clientId } = feature.oidc;
|
||||
const redirectUri = `${ window.location.origin }${ REDIRECT_URI_PATH }`;
|
||||
const state = crypto.randomUUID();
|
||||
sessionStorage.setItem('oidc_state', state);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: 'openid profile email',
|
||||
state,
|
||||
});
|
||||
|
||||
return `${ serverUrl }/login/oauth/authorize?${ params.toString() }`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
buttonSize?: ButtonProps['size'];
|
||||
buttonVariant?: ButtonProps['variant'];
|
||||
}
|
||||
|
||||
const UserProfileOidc = ({ buttonSize, buttonVariant = 'header' }: Props) => {
|
||||
const profileQuery = useProfileQuery();
|
||||
const profileMenu = useDisclosure();
|
||||
const logout = useLogout();
|
||||
|
||||
const handleButtonClick = React.useCallback(() => {
|
||||
if (profileQuery.data) {
|
||||
profileMenu.onOpen();
|
||||
return;
|
||||
}
|
||||
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 });
|
||||
}, [ profileMenu ]);
|
||||
|
||||
const isLoading = profileQuery.isLoading;
|
||||
const data = profileQuery.data;
|
||||
|
||||
const buttonLabel = (() => {
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
if (data) {
|
||||
const handle = data.email ? data.email.split('@')[0] : 'My account';
|
||||
return (
|
||||
<Flex align="center" gap={ 2 }>
|
||||
<IconSvg name="profile" boxSize={ 5 }/>
|
||||
<Box display={{ base: 'none', md: 'block' }}>{ handle }</Box>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
return 'Log in';
|
||||
})();
|
||||
|
||||
return (
|
||||
<PopoverRoot
|
||||
positioning={{ placement: 'bottom-end' }}
|
||||
open={ profileMenu.open }
|
||||
onOpenChange={ handleMenuOpenChange }
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
size={ buttonSize }
|
||||
variant={ buttonVariant }
|
||||
onClick={ handleButtonClick }
|
||||
loading={ isLoading }
|
||||
selected={ Boolean(data) }
|
||||
px={{ base: 2.5, lg: 3 }}
|
||||
fontWeight={ data ? 700 : 600 }
|
||||
>
|
||||
{ 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.id ?? '')) }
|
||||
</Box>
|
||||
<Separator/>
|
||||
<Link href="/auth/profile" textStyle="sm" color="text.primary" px={ 1 }>
|
||||
My profile
|
||||
</Link>
|
||||
<Button variant="ghost" size="sm" onClick={ handleLogout } colorPalette="red" justifyContent="flex-start" px={ 1 }>
|
||||
Sign out
|
||||
</Button>
|
||||
</Flex>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
) }
|
||||
</PopoverRoot>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(UserProfileOidc);
|
||||
Reference in New Issue
Block a user