diff --git a/src/app/[locale]/[username]/communities/page.tsx b/src/app/[locale]/[username]/communities/page.tsx new file mode 100644 index 00000000..6eecb720 --- /dev/null +++ b/src/app/[locale]/[username]/communities/page.tsx @@ -0,0 +1,5 @@ +import WalletPage from '../page'; + +export default function CommunitiesPage() { + return ; +} diff --git a/src/app/[locale]/[username]/page.tsx b/src/app/[locale]/[username]/page.tsx index 4c82abcf..181b1171 100644 --- a/src/app/[locale]/[username]/page.tsx +++ b/src/app/[locale]/[username]/page.tsx @@ -7,12 +7,14 @@ import { usePathname, useRouter } from '@/i18n/routing'; import { RecentActivityLazy } from '@/components/wallet/client-wrappers'; import { BalanceRows } from '@/components/wallet/balance-rows'; import { ClaimRewardsBanner } from '@/components/wallet/claim-rewards-banner'; -import { WalletSubMenu } from '@/components/layout/wallet-sub-menu'; import { useSteemWalletBalances } from '@/hooks/use-steem-wallet-balances'; -import { UserProfileBanner, TopNav } from '@/components/layout/user-profile-banner'; +import { UserProfileBanner } from '@/components/layout/user-profile-banner'; +import { AccountWalletNav } from '@/components/layout/account-wallet-nav'; import { Skeleton } from '@/components/ui/skeleton'; import { WalletTransfersModals } from '@/components/wallet/wallet-transfers-modals'; +import { WalletSectionPlaceholder } from '@/components/wallet/wallet-section-placeholder'; import { normalizeProfile } from '@/lib/steem/normalize-profile'; +import { canManageBalanceForPageUrl } from '@/lib/auth/browser-storage'; type BannerProfileFields = { displayName?: string; @@ -52,6 +54,11 @@ export default function WalletPage() { // Parse username from params (e.g. "@ety001" -> "ety001") const urlUsername = rawUsername ? decodeURIComponent(rawUsername).replace(/^@/, '') : ''; const isMyAccount = !!isAuthenticated && !!loggedInUser && loggedInUser === urlUsername; + const showBalanceActions = canManageBalanceForPageUrl({ + urlUsername, + loggedInUser, + isAuthenticated, + }); const [walletRefreshNonce, setWalletRefreshNonce] = useState(0); @@ -125,6 +132,12 @@ export default function WalletPage() { ); } + const isTransfersPath = pathname?.includes('/transfers') ?? false; + const isDelegationsPath = pathname?.includes('/delegations') ?? false; + const isPermissionsPath = pathname?.includes('/permissions') ?? false; + const isPasswordPath = pathname?.includes('/password') ?? false; + const isCommunitiesPath = pathname?.includes('/communities') ?? false; + return (
{/* User Profile Banner - matches legacy UserProfile.jsx */} @@ -134,39 +147,46 @@ export default function WalletPage() { {...(bannerProfile ?? {})} /> - {/* Top Navigation - Blog | Wallet | Rewards */} - - {/* Wallet Content Area — claim banner above sub menu matches wallet-legacy UserWallet.jsx */} -
- - - - -
- + {isTransfersPath && ( + -
- - {/* Recent Activity - lazy loaded */} - + )} + + {isTransfersPath && ( + <> + + + + )} + {isDelegationsPath && } + {isPermissionsPath && } + {isPasswordPath && } + {isCommunitiesPath && }
- setWalletRefreshNonce((n) => n + 1)} - /> + {isTransfersPath && ( + setWalletRefreshNonce((n) => n + 1)} + /> + )}
); diff --git a/src/app/[locale]/[username]/password/page.tsx b/src/app/[locale]/[username]/password/page.tsx new file mode 100644 index 00000000..80979d6f --- /dev/null +++ b/src/app/[locale]/[username]/password/page.tsx @@ -0,0 +1,5 @@ +import WalletPage from '../page'; + +export default function ChangePasswordPage() { + return ; +} diff --git a/src/app/[locale]/[username]/permissions/page.tsx b/src/app/[locale]/[username]/permissions/page.tsx new file mode 100644 index 00000000..738a6ca1 --- /dev/null +++ b/src/app/[locale]/[username]/permissions/page.tsx @@ -0,0 +1,5 @@ +import WalletPage from '../page'; + +export default function PermissionsPage() { + return ; +} diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index 629d5abb..9a50975a 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -1,9 +1,5 @@ -import { LoginForm } from '@/components/auth/login-form'; +import { HomeRedirectOrLogin } from '@/components/home/home-redirect-or-login'; export default function HomePage() { - return ( -
- -
- ); + return ; } diff --git a/src/app/globals.css b/src/app/globals.css index a0b435a5..b98c2e46 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -295,12 +295,6 @@ /* UserWallet balance rows: use Tailwind py-4 px-4 on WalletBalanceRowShell only — avoid global .UserWallet__balance padding; unlayered CSS overrides @layer utilities. */ -/* WalletSubMenu */ -.WalletSubMenu a.active { - font-weight: 700; - color: var(--foreground); -} - /* Wallet dropdown - match legacy style */ .Wallet_dropdown { cursor: pointer; diff --git a/src/components/auth/login-form.tsx b/src/components/auth/login-form.tsx index 7f522a97..bc2bb5c7 100644 --- a/src/components/auth/login-form.tsx +++ b/src/components/auth/login-form.tsx @@ -8,6 +8,7 @@ import { AppDispatch } from '@/lib/store'; import { setCredentials } from '@/lib/store/slices/auth'; import { SteemSigner, apiClient } from '@/lib/steem/client'; import { + normalizeSteemUsername, REMEMBERED_POSTING_KEY_KEY, REMEMBERED_USERNAME_KEY, } from '@/lib/auth/browser-storage'; @@ -27,33 +28,45 @@ interface LoginFormData { password: string; } -export function LoginForm() { +export interface LoginFormProps { + /** Wallet modal re-auth: skip navigation and call onLoginSuccess. */ + embedded?: boolean; + /** Lock username to this account (normalized). */ + fixedUsername?: string; + onLoginSuccess?: () => void; +} + +export function LoginForm(props: LoginFormProps = {}) { + const { embedded = false, fixedUsername, onLoginSuccess } = props; const t = useTranslations('auth'); const tCommon = useTranslations('common'); const dispatch = useDispatch(); const router = useRouter(); const [isPending, startTransition] = useTransition(); - const [formData, setFormData] = useState({ - username: '', + const [formData, setFormData] = useState(() => ({ + username: fixedUsername ? normalizeSteemUsername(fixedUsername) : '', password: '', - }); + })); const [error, setError] = useState(''); const [isLoading, setIsLoading] = useState(false); const [rememberUser, setRememberUser] = useState(false); - // Read remembered username only after mount so SSR and the first client render match. useEffect(() => { - try { - const saved = localStorage.getItem(REMEMBERED_USERNAME_KEY) ?? ''; - if (saved) { - setFormData((prev) => ({ ...prev, username: saved })); - setRememberUser(true); + if (embedded || fixedUsername) return; + const id = requestAnimationFrame(() => { + try { + const saved = localStorage.getItem(REMEMBERED_USERNAME_KEY) ?? ''; + if (saved) { + setFormData((prev) => ({ ...prev, username: normalizeSteemUsername(saved) })); + setRememberUser(true); + } + } catch { + // ignore } - } catch { - // ignore - } - }, []); + }); + return () => cancelAnimationFrame(id); + }, [embedded, fixedUsername]); const handleChange = (e: React.ChangeEvent) => { const { name, value } = e.target; @@ -67,7 +80,9 @@ export function LoginForm() { setIsLoading(true); try { - const username = formData.username.trim().toLowerCase().replace(/^@+/, ''); + const username = fixedUsername + ? normalizeSteemUsername(fixedUsername) + : normalizeSteemUsername(formData.username); const rawSecret = formData.password.trim(); if (!username || !rawSecret) { @@ -242,10 +257,14 @@ export function LoginForm() { // ignore } - // Navigate to user's wallet (transfers tab) with /@username in the path (not %40) - startTransition(() => { - router.push(transfersPathForUsername(username)); - }); + setIsLoading(false); + if (embedded) { + onLoginSuccess?.(); + } else { + startTransition(() => { + router.push(transfersPathForUsername(username)); + }); + } } catch (err) { console.error('Login error:', err); setError(tCommon('error')); @@ -253,14 +272,21 @@ export function LoginForm() { } }; + const usernameInputId = embedded ? 'wallet-reauth-username' : 'username'; + return ( -
- {/* Login Form Card */} -
+
+
{/* Username Input with @ prefix */}
-
- {/* Save Login Option */} -
- setRememberUser(value === true)} - disabled={isLoading || isPending} - className="peer mt-0.5 border-muted-foreground/50 data-[state=unchecked]:bg-background" - /> - -
+ {!embedded && ( +
+ setRememberUser(value === true)} + disabled={isLoading || isPending} + className="peer mt-0.5 border-muted-foreground/50 data-[state=unchecked]:bg-background" + /> + +
+ )} {/* Error Message */} {error && ( diff --git a/src/components/home/home-redirect-or-login.tsx b/src/components/home/home-redirect-or-login.tsx new file mode 100644 index 00000000..3912a0c9 --- /dev/null +++ b/src/components/home/home-redirect-or-login.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from '@/i18n/routing'; +import { LoginForm } from '@/components/auth/login-form'; +import { Skeleton } from '@/components/ui/skeleton'; +import { getRememberedDeviceUsername } from '@/lib/auth/browser-storage'; +import { transfersPathForUsername } from '@/lib/wallet/wallet-modal-search-params'; + +/** + * Root path: redirect to /@rememberedUser/transfers when a username is stored locally; + * otherwise show the login form. + */ +export function HomeRedirectOrLogin() { + const router = useRouter(); + const [phase, setPhase] = useState<'checking' | 'login'>('checking'); + + useEffect(() => { + const remembered = getRememberedDeviceUsername(); + if (remembered) { + router.replace(transfersPathForUsername(remembered)); + return; + } + const id = requestAnimationFrame(() => setPhase('login')); + return () => cancelAnimationFrame(id); + }, [router]); + + if (phase === 'checking') { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/src/components/layout/account-wallet-nav.tsx b/src/components/layout/account-wallet-nav.tsx new file mode 100644 index 00000000..46aed331 --- /dev/null +++ b/src/components/layout/account-wallet-nav.tsx @@ -0,0 +1,154 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { ChevronDown, ExternalLink } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { cn } from '@/lib/utils'; + +const POLONIEX_STEEM_TRX = 'https://poloniex.com/trade/STEEM_TRX/?type=spot'; + +const externalNavLinkClassName = + 'inline-flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent/80 hover:text-accent-foreground'; + +export interface AccountWalletNavProps { + accountname: string; + socialUrl?: string; + isMyAccount: boolean; + showOwnerWalletNav?: boolean; +} + +/** + * Single wallet area nav: balances, rewards, delegations, owner tabs, then Blog + Buy STEEM (plain external links, right). + */ +export function AccountWalletNav({ + accountname, + socialUrl = 'https://steemit.com', + isMyAccount, + showOwnerWalletNav = false, +}: AccountWalletNavProps) { + const t = useTranslations('wallet'); + const pathname = usePathname(); + + const showExtraTabs = !!(showOwnerWalletNav || isMyAccount); + + const isRewardsActive = + pathname?.includes('/curation-rewards') || pathname?.includes('/author-rewards'); + + const navInactive = + 'inline-flex items-center rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent/80 hover:text-accent-foreground'; + const navActive = + 'inline-flex items-center rounded-md px-3 py-2 text-sm font-semibold bg-accent text-accent-foreground'; + + const isPathActive = (segment: string) => pathname?.includes(segment) ?? false; + + const transfersHref = `/@${accountname}/transfers`; + const delegationsHref = `/@${accountname}/delegations`; + const permissionsHref = `/@${accountname}/permissions`; + const communitiesHref = `/@${accountname}/communities`; + const passwordHref = `/@${accountname}/password`; + + const balancesActive = isPathActive('/transfers'); + const delegationsActive = isPathActive('/delegations'); + const permissionsActive = isPathActive('/permissions'); + const communitiesActive = isPathActive('/communities'); + const passwordActive = isPathActive('/password'); + + return ( +
+
+
+
    +
  • + + {t('balances')} + +
  • +
  • + + + Rewards + + + + Curation Rewards + + + Author Rewards + + + +
  • +
  • + + {t('delegations')} + +
  • + {showExtraTabs && ( + <> +
  • + + {t('keysAndPermissions')} + +
  • +
  • + + {t('communities')} + +
  • +
  • + + {t('changePassword')} + +
  • + + )} +
+ + +
+
+
+ ); +} diff --git a/src/components/layout/user-profile-banner.tsx b/src/components/layout/user-profile-banner.tsx index 754aba64..346793cb 100644 --- a/src/components/layout/user-profile-banner.tsx +++ b/src/components/layout/user-profile-banner.tsx @@ -1,15 +1,8 @@ 'use client'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { MapPin, LinkIcon, Calendar, ChevronDown, ExternalLink } from 'lucide-react'; +import { MapPin, LinkIcon, Calendar } from 'lucide-react'; import { useFormatter, useTranslations } from 'next-intl'; -import { cn } from '@/lib/utils'; interface UserProfileBannerProps { accountname: string; @@ -102,69 +95,3 @@ export function UserProfileBanner({ ); } -interface TopNavProps { - accountname: string; - socialUrl?: string; - activeSection?: string; -} - -export function TopNav({ accountname, socialUrl = 'https://steemit.com', activeSection }: TopNavProps) { - const isRewardsActive = activeSection === 'curation-rewards' || activeSection === 'author-rewards'; - const isWalletActive = !isRewardsActive; - - return ( -
-
-
- -
-
-
- ); -} diff --git a/src/components/layout/wallet-sub-menu.tsx b/src/components/layout/wallet-sub-menu.tsx deleted file mode 100644 index c1f39072..00000000 --- a/src/components/layout/wallet-sub-menu.tsx +++ /dev/null @@ -1,72 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; -import { useTranslations } from 'next-intl'; -import { Button } from '@/components/ui/button'; -import { cn } from '@/lib/utils'; - -interface WalletSubMenuProps { - accountname: string; - isMyAccount: boolean; -} - -export function WalletSubMenu({ accountname, isMyAccount }: WalletSubMenuProps) { - const t = useTranslations('wallet'); - const pathname = usePathname(); - - const links = [ - { href: `/@${accountname}/transfers`, label: t('balances', { defaultMessage: 'Balances' }) }, - { href: `/@${accountname}/delegations`, label: t('delegations', { defaultMessage: 'Delegations' }) }, - ...(isMyAccount ? [ - { href: `/@${accountname}/permissions`, label: 'Permissions' }, - { href: `/@${accountname}/password`, label: 'Change Password' }, - { href: `/@${accountname}/communities`, label: 'Communities' }, - ] : []), - ]; - - const handleBuySteem = (e: React.MouseEvent) => { - e.preventDefault(); - const newWindow = window.open(); - if (newWindow) { - newWindow.opener = null; - newWindow.location.href = 'https://poloniex.com/trade/STEEM_TRX/?type=spot'; - } - }; - - return ( -
-
    - {links.map((link) => { - const isActive = pathname?.includes(link.href); - return ( -
  • - - {link.label} - -
  • - ); - })} -
- - {isMyAccount && ( - - )} -
- ); -} diff --git a/src/components/wallet/balance-rows.tsx b/src/components/wallet/balance-rows.tsx index b62acfc8..a8e72830 100644 --- a/src/components/wallet/balance-rows.tsx +++ b/src/components/wallet/balance-rows.tsx @@ -41,11 +41,14 @@ export function BalanceRows({ balance, globalProps, loading, + showBalanceActions = true, }: { username: string; balance: WalletBalanceData | null; globalProps: GlobalPropsData | null; loading: boolean; + /** When false, hide per-asset dropdowns (view-only balances). */ + showBalanceActions?: boolean; }) { const t = useTranslations('wallet'); @@ -137,6 +140,7 @@ export function BalanceRows({ {selected}; + } + return ( diff --git a/src/components/wallet/claim-rewards-banner.tsx b/src/components/wallet/claim-rewards-banner.tsx index cda37694..7cda2fea 100644 --- a/src/components/wallet/claim-rewards-banner.tsx +++ b/src/components/wallet/claim-rewards-banner.tsx @@ -21,7 +21,7 @@ export function ClaimRewardsBanner({ } return ( -
+
Your current rewards: {buildRewardsDisplayStr(balance)} diff --git a/src/components/wallet/convert-sbd-form.tsx b/src/components/wallet/convert-sbd-form.tsx index be230fb3..eb48facf 100644 --- a/src/components/wallet/convert-sbd-form.tsx +++ b/src/components/wallet/convert-sbd-form.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'; import { useTranslations } from 'next-intl'; import { useSelector } from 'react-redux'; import type { RootState } from '@/lib/store'; -import { usePrivateKey } from '@/hooks/use-auth'; +import { useActiveSigningKey } from '@/hooks/use-auth'; import { SteemSigner, apiClient } from '@/lib/steem/client'; import type { SignedTransaction } from '@/lib/steem/types'; import type { SteemAccount } from '@/lib/steem/types'; @@ -43,7 +43,7 @@ export function ConvertSbdForm({ const t = useTranslations('wallet.convertSbd'); const tCommon = useTranslations('common'); const loggedIn = useSelector((state: RootState) => state.auth.username); - const privateKey = usePrivateKey(); + const signingKey = useActiveSigningKey(); const [amount, setAmount] = useState(''); const [acknowledged, setAcknowledged] = useState(false); @@ -106,7 +106,7 @@ export function ConvertSbdForm({ isMyAccount && !!loggedIn && loggedIn === accountUsername && - !!privateKey && + !!signingKey && amountNum > 0 && amountNum <= sbdMax + 1e-9 && acknowledged && @@ -115,7 +115,7 @@ export function ConvertSbdForm({ const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); - if (!privateKey || !loggedIn || loggedIn !== accountUsername) { + if (!signingKey || !loggedIn || loggedIn !== accountUsername) { setError(t('mustBeAccountOwner')); return; } @@ -135,7 +135,7 @@ export function ConvertSbdForm({ accountUsername, requestid, amountStr, - privateKey + signingKey ); const res = await apiClient.broadcastConvert(signedTx, loggedIn); if (!res.success) { diff --git a/src/components/wallet/delegate-form.tsx b/src/components/wallet/delegate-form.tsx index a72cd968..b17b4069 100644 --- a/src/components/wallet/delegate-form.tsx +++ b/src/components/wallet/delegate-form.tsx @@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation'; import { useTranslations } from 'next-intl'; import { useSelector } from 'react-redux'; import type { RootState } from '@/lib/store'; -import { usePrivateKey } from '@/hooks/use-auth'; +import { useActiveSigningKey } from '@/hooks/use-auth'; import { SteemSigner, apiClient } from '@/lib/steem/client'; import { Button } from '@/components/ui/button'; import { @@ -34,7 +34,7 @@ export function DelegateForm({ const tCommon = useTranslations('common'); const router = useRouter(); const username = useSelector((state: RootState) => state.auth.username); - const privateKey = usePrivateKey(); + const signingKey = useActiveSigningKey(); const [isPending, startTransition] = useTransition(); const [delegatee, setDelegatee] = useState(''); @@ -47,7 +47,7 @@ export function DelegateForm({ setError(''); setIsLoading(true); - if (!username || !privateKey) { + if (!username || !signingKey) { setError('Not authenticated'); setIsLoading(false); return; @@ -68,7 +68,7 @@ export function DelegateForm({ } const vests = `${shareValue.toFixed(6)} VESTS`; - const signedTx = SteemSigner.signDelegate(username, delegatee.trim(), vests, privateKey); + const signedTx = SteemSigner.signDelegate(username, delegatee.trim(), vests, signingKey); const response = await apiClient.broadcastDelegate(signedTx, username); if (!response.success) { diff --git a/src/components/wallet/power-down-form.tsx b/src/components/wallet/power-down-form.tsx index edbdbe55..66903bd2 100644 --- a/src/components/wallet/power-down-form.tsx +++ b/src/components/wallet/power-down-form.tsx @@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation'; import { useTranslations } from 'next-intl'; import { useSelector } from 'react-redux'; import type { RootState } from '@/lib/store'; -import { usePrivateKey } from '@/hooks/use-auth'; +import { useActiveSigningKey } from '@/hooks/use-auth'; import { useAccountData } from '@/hooks/use-account-data'; import { SteemSigner, apiClient } from '@/lib/steem/client'; import { Button } from '@/components/ui/button'; @@ -30,7 +30,7 @@ export function PowerDownForm({ variant = 'page', onSuccess }: PowerDownFormProp const tCommon = useTranslations('common'); const router = useRouter(); const username = useSelector((state: RootState) => state.auth.username); - const privateKey = usePrivateKey(); + const signingKey = useActiveSigningKey(); const [isPending, startTransition] = useTransition(); const { data: account, refetch } = useAccountData(); @@ -58,7 +58,7 @@ export function PowerDownForm({ variant = 'page', onSuccess }: PowerDownFormProp setError(''); setIsLoading(true); - if (!username || !privateKey) { + if (!username || !signingKey) { setError('Not authenticated'); setIsLoading(false); return; @@ -73,7 +73,7 @@ export function PowerDownForm({ variant = 'page', onSuccess }: PowerDownFormProp } const vests = `${shareValue.toFixed(6)} VESTS`; - const signedTx = SteemSigner.signPowerDown(username, vests, privateKey); + const signedTx = SteemSigner.signPowerDown(username, vests, signingKey); const response = await apiClient.broadcastPowerDown(signedTx, username); if (!response.success) { @@ -91,7 +91,7 @@ export function PowerDownForm({ variant = 'page', onSuccess }: PowerDownFormProp }; const handleCancelPowerDown = async () => { - if (!username || !privateKey) { + if (!username || !signingKey) { setError('Not authenticated'); return; } @@ -101,7 +101,7 @@ export function PowerDownForm({ variant = 'page', onSuccess }: PowerDownFormProp try { const vests = '0.000000 VESTS'; - const signedTx = SteemSigner.signPowerDown(username, vests, privateKey); + const signedTx = SteemSigner.signPowerDown(username, vests, signingKey); const response = await apiClient.broadcastPowerDown(signedTx, username); if (!response.success) { diff --git a/src/components/wallet/transfer-form.tsx b/src/components/wallet/transfer-form.tsx index f897c46c..60a08ba8 100644 --- a/src/components/wallet/transfer-form.tsx +++ b/src/components/wallet/transfer-form.tsx @@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation'; import { useTranslations } from 'next-intl'; import { useSelector } from 'react-redux'; import type { RootState } from '@/lib/store'; -import { usePrivateKey } from '@/hooks/use-auth'; +import { useActiveSigningKey } from '@/hooks/use-auth'; import { SteemSigner, apiClient } from '@/lib/steem/client'; import type { SignedTransaction } from '@/lib/steem/types'; import { Button } from '@/components/ui/button'; @@ -44,7 +44,7 @@ export function TransferForm({ const tCommon = useTranslations('common'); const router = useRouter(); const username = useSelector((state: RootState) => state.auth.username); - const privateKey = usePrivateKey(); + const signingKey = useActiveSigningKey(); const [isPending, startTransition] = useTransition(); const [transferType, setTransferType] = useState(initialTransferType); @@ -54,8 +54,11 @@ export function TransferForm({ const [isLoading, setIsLoading] = useState(false); useEffect(() => { - setTransferType(initialTransferType); - setAsset(initialAsset === 'SBD' ? 'SBD' : 'STEEM'); + const id = requestAnimationFrame(() => { + setTransferType(initialTransferType); + setAsset(initialAsset === 'SBD' ? 'SBD' : 'STEEM'); + }); + return () => cancelAnimationFrame(id); }, [initialAsset, initialTransferType]); const handleChange = (e: React.ChangeEvent) => { @@ -72,7 +75,7 @@ export function TransferForm({ setError(''); setIsLoading(true); - if (!username || !privateKey) { + if (!username || !signingKey) { setError('Not authenticated'); setIsLoading(false); return; @@ -106,7 +109,7 @@ export function TransferForm({ formData.to.trim(), amountStr, formData.memo, - privateKey + signingKey ); } else if (transferType === 'savings') { signedTx = SteemSigner.signTransferToSavings( @@ -114,7 +117,7 @@ export function TransferForm({ username, amountStr, formData.memo, - privateKey + signingKey ); } else if (transferType === 'savings_withdraw') { const requestId = Date.now() >>> 0; @@ -124,14 +127,14 @@ export function TransferForm({ amountStr, formData.memo, requestId, - privateKey + signingKey ); } else if (transferType === 'power_up') { signedTx = SteemSigner.signTransferToVesting( username, username, amountStr, - privateKey + signingKey ); } else { setError('Unsupported operation'); diff --git a/src/components/wallet/wallet-section-placeholder.tsx b/src/components/wallet/wallet-section-placeholder.tsx new file mode 100644 index 00000000..f6beb9b7 --- /dev/null +++ b/src/components/wallet/wallet-section-placeholder.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { useTranslations } from 'next-intl'; + +export interface WalletSectionPlaceholderProps { + /** i18n key under `wallet` namespace */ + titleKey: string; +} + +export function WalletSectionPlaceholder({ titleKey }: WalletSectionPlaceholderProps) { + const t = useTranslations('wallet'); + + return ( +
+

{t(titleKey)}

+

+ {t('sectionPlaceholderHint')} +

+
+ ); +} diff --git a/src/components/wallet/wallet-transfers-modals.tsx b/src/components/wallet/wallet-transfers-modals.tsx index 64477326..a4a28545 100644 --- a/src/components/wallet/wallet-transfers-modals.tsx +++ b/src/components/wallet/wallet-transfers-modals.tsx @@ -1,10 +1,15 @@ 'use client'; -import { useCallback } from 'react'; +import { useCallback, useEffect } from 'react'; import { useParams, useSearchParams } from 'next/navigation'; import { useTranslations } from 'next-intl'; import { usePathname, useRouter } from '@/i18n/routing'; -import { useAuth } from '@/hooks/use-auth'; +import { useAuth, useActiveSigningKey } from '@/hooks/use-auth'; +import { + canManageBalanceForPageUrl, + normalizeSteemUsername, +} from '@/lib/auth/browser-storage'; +import { LoginForm } from '@/components/auth/login-form'; import { WALLET_ACTION_QUERY, WALLET_ASSET_QUERY, @@ -35,6 +40,7 @@ export function WalletTransfersModals({ onWalletDataChanged }: WalletTransfersMo const t = useTranslations('wallet'); const params = useParams(); const { username: loggedInUser, isAuthenticated } = useAuth(); + const activeSigningKey = useActiveSigningKey(); const pathname = usePathname(); const router = useRouter(); const searchParams = useSearchParams(); @@ -43,6 +49,17 @@ export function WalletTransfersModals({ onWalletDataChanged }: WalletTransfersMo const accountUsername = rawUsername ? decodeURIComponent(rawUsername).replace(/^@/, '') : ''; const isMyAccount = !!isAuthenticated && !!loggedInUser && loggedInUser === accountUsername; + const canManageBalance = canManageBalanceForPageUrl({ + urlUsername: accountUsername, + loggedInUser, + isAuthenticated, + }); + + const sessionMatchesPage = + !isAuthenticated || + (!!loggedInUser && + normalizeSteemUsername(loggedInUser) === normalizeSteemUsername(accountUsername)); + const walletAction = parseWalletModalAction(searchParams.get(WALLET_ACTION_QUERY)); const asset = parseWalletAsset(searchParams.get(WALLET_ASSET_QUERY)); const transferType = parseWalletTransferType(searchParams.get(WALLET_TYPE_QUERY)); @@ -65,13 +82,41 @@ export function WalletTransfersModals({ onWalletDataChanged }: WalletTransfersMo if (!next) clearWalletQuery(); }; + useEffect(() => { + if (walletAction === null || !accountUsername) return; + if (!canManageBalance) { + clearWalletQuery(); + } + }, [walletAction, accountUsername, canManageBalance, clearWalletQuery]); + + const needsWalletReauth = + open && + canManageBalance && + accountUsername && + (!sessionMatchesPage || activeSigningKey === null); + return ( e.preventDefault()} > - {walletAction === 'transfer' && ( + {needsWalletReauth && ( + <> + + {t('reauthTitle')} + {t('reauthDescription')} + + { + /* Redux updates; modal re-renders into the wallet form */ + }} + /> + + )} + {!needsWalletReauth && walletAction === 'transfer' && ( <> Transfer @@ -86,7 +131,7 @@ export function WalletTransfersModals({ onWalletDataChanged }: WalletTransfersMo /> )} - {walletAction === 'powerDown' && ( + {!needsWalletReauth && walletAction === 'powerDown' && ( <> Power down @@ -94,7 +139,7 @@ export function WalletTransfersModals({ onWalletDataChanged }: WalletTransfersMo )} - {walletAction === 'delegate' && ( + {!needsWalletReauth && walletAction === 'delegate' && ( <> Delegate vesting shares @@ -102,7 +147,7 @@ export function WalletTransfersModals({ onWalletDataChanged }: WalletTransfersMo )} - {walletAction === 'advanced' && ( + {!needsWalletReauth && walletAction === 'advanced' && ( <> {t('withdrawRoutes.title')} @@ -119,7 +164,7 @@ export function WalletTransfersModals({ onWalletDataChanged }: WalletTransfersMo /> )} - {walletAction === 'convert' && ( + {!needsWalletReauth && walletAction === 'convert' && ( <> {t('convertSbd.title')} diff --git a/src/components/wallet/withdraw-routes-form.tsx b/src/components/wallet/withdraw-routes-form.tsx index a02494a8..964d9e08 100644 --- a/src/components/wallet/withdraw-routes-form.tsx +++ b/src/components/wallet/withdraw-routes-form.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useTranslations } from 'next-intl'; import { useSelector } from 'react-redux'; import type { RootState } from '@/lib/store'; -import { usePrivateKey } from '@/hooks/use-auth'; +import { useActiveSigningKey } from '@/hooks/use-auth'; import { SteemSigner, apiClient } from '@/lib/steem/client'; import type { SignedTransaction } from '@/lib/steem/types'; import { Button } from '@/components/ui/button'; @@ -45,7 +45,7 @@ export function WithdrawRoutesForm({ const t = useTranslations('wallet.withdrawRoutes'); const tCommon = useTranslations('common'); const loggedIn = useSelector((state: RootState) => state.auth.username); - const privateKey = usePrivateKey(); + const signingKey = useActiveSigningKey(); const [routes, setRoutes] = useState([]); const [loading, setLoading] = useState(true); @@ -84,7 +84,7 @@ export function WithdrawRoutesForm({ const remainingDisplay = Math.max(0, 100 - totalRoutedChain / 100); const submitRoute = async (toAccount: string, chainPercent: number, vest: boolean) => { - if (!loggedIn || !privateKey || loggedIn !== accountUsername) { + if (!loggedIn || !signingKey || loggedIn !== accountUsername) { setError(t('mustBeAccountOwner')); return; } @@ -96,7 +96,7 @@ export function WithdrawRoutesForm({ toAccount, chainPercent, vest, - privateKey + signingKey ); const res = await apiClient.broadcastSetWithdrawVestingRoute(signedTx, loggedIn); if (!res.success) { @@ -135,7 +135,7 @@ export function WithdrawRoutesForm({ void submitRoute(toAccount, 0, false); }; - const canEdit = isMyAccount && !!loggedIn && loggedIn === accountUsername && !!privateKey; + const canEdit = isMyAccount && !!loggedIn && loggedIn === accountUsername && !!signingKey; const inner = (
diff --git a/src/components/wallet/witness-vote-form.tsx b/src/components/wallet/witness-vote-form.tsx index d3178339..34b88c36 100644 --- a/src/components/wallet/witness-vote-form.tsx +++ b/src/components/wallet/witness-vote-form.tsx @@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation'; import { useTranslations } from 'next-intl'; import { useSelector } from 'react-redux'; import type { RootState } from '@/lib/store'; -import { usePrivateKey } from '@/hooks/use-auth'; +import { useActiveSigningKey } from '@/hooks/use-auth'; import { useAccountData } from '@/hooks/use-account-data'; import { SteemSigner, apiClient } from '@/lib/steem/client'; import type { Witness } from '@/lib/steem/types'; @@ -21,7 +21,7 @@ export function WitnessVoteForm() { const tCommon = useTranslations('common'); const router = useRouter(); const username = useSelector((state: RootState) => state.auth.username); - const privateKey = usePrivateKey(); + const signingKey = useActiveSigningKey(); const [isPending, startTransition] = useTransition(); const { data: account } = useAccountData(); @@ -80,7 +80,7 @@ export function WitnessVoteForm() { const handleVote = async (witness: Witness, approve: boolean) => { setError(''); - if (!username || !privateKey) { + if (!username || !signingKey) { setError('Not authenticated'); return; } @@ -88,7 +88,7 @@ export function WitnessVoteForm() { setIsLoading(true); try { // Sign transaction - const signedTx = SteemSigner.signWitnessVote(username, witness.owner, approve, privateKey); + const signedTx = SteemSigner.signWitnessVote(username, witness.owner, approve, signingKey); // Broadcast const response = await apiClient.broadcastWitnessVote(signedTx, username); diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index e077beba..05209af0 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -179,3 +179,11 @@ export function useRequireAuth(): { username: string | null; isAuthenticated: bo export function usePrivateKey(): string | null { return useSelector((state: RootState) => state.auth.activeKey || state.auth.privateKey); } + +/** + * Private key that can sign operations requiring active (or owner) authority. + * Do not use posting/memo keys for transfers / power / delegate / etc. + */ +export function useActiveSigningKey(): string | null { + return useSelector((state: RootState) => state.auth.activeKey || state.auth.ownerKey || null); +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 9f15f8f0..75cdd149 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -53,6 +53,10 @@ "transfer": "Transfer", "powerDown": "Power Down", "delegations": "Delegations", + "keysAndPermissions": "Keys & Permissions", + "changePassword": "Change Password", + "communities": "Communities", + "sectionPlaceholderHint": "This page is a placeholder. Full functionality will be added in a future update.", "witnesses": "Witnesses", "witnessVoting": "Witness Voting", "history": "History", @@ -72,8 +76,10 @@ "effectiveSP": "Effective Steem Power", "receivedVests": "Received VESTS", "delegatedVests": "Delegated VESTS", - "buySteem": "Buy Steem or Steem Power", + "buySteem": "Buy STEEM", "claimRewards": "Redeem Rewards", + "reauthTitle": "Sign in to continue", + "reauthDescription": "This action requires your active key. Enter your password or private key.", "liquidBalanceDesc": "Liquid token, transferable at any time.", "powerDesc": "Influence token that gives you more influence.", "sbdDesc": "Tradeable tokens that can be transferred to savings.", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 663dd2d0..f1af7e9a 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -53,6 +53,10 @@ "transfer": "Transferir", "powerDown": "Bajar Energía", "delegations": "Delegaciones", + "keysAndPermissions": "Claves y permisos", + "changePassword": "Cambiar contraseña", + "communities": "Comunidades", + "sectionPlaceholderHint": "Esta página es provisional. La función completa se añadirá más adelante.", "witnesses": "Testigos", "witnessVoting": "Votación de Testigos", "history": "Historial", @@ -76,9 +80,12 @@ "navDiscover": "Descubrir", "navAccess": "Acceso", "blog": "Blog", + "buySteem": "Comprar STEEM", "proposals": "Propuestas", "market": "Mercado", "profileJoined": "Miembro desde", + "reauthTitle": "Inicia sesión para continuar", + "reauthDescription": "Esta acción requiere tu clave activa. Introduce tu contraseña o clave privada.", "withdrawRoutes": { "title": "Rutas de retiro de Power down", "dialogDescription": "Administra a dónde se envía el STEEM del power down.", diff --git a/src/i18n/messages/zh.json b/src/i18n/messages/zh.json index c311256c..174cf549 100644 --- a/src/i18n/messages/zh.json +++ b/src/i18n/messages/zh.json @@ -53,6 +53,10 @@ "transfer": "转账", "powerDown": "能量下调", "delegations": "委托", + "keysAndPermissions": "密钥与权限", + "changePassword": "修改密码", + "communities": "社区", + "sectionPlaceholderHint": "本页为占位界面,完整功能将在后续版本提供。", "witnesses": "见证人", "witnessVoting": "见证人投票", "history": "历史", @@ -72,8 +76,10 @@ "effectiveSP": "有效 Steem Power", "receivedVests": "收到的 VESTS", "delegatedVests": "委托的 VESTS", - "buySteem": "购买 STEEM 或 STEEM POWER", + "buySteem": "购买 STEEM", "claimRewards": "领取奖励", + "reauthTitle": "请登录后继续", + "reauthDescription": "此操作需要 active 权限。请输入主密码或私钥。", "liquidBalanceDesc": "流动代币,可随时转账。", "powerDesc": "影响力代币,给予您更多影响力。", "sbdDesc": "可交易的代币,可转入储蓄。", diff --git a/src/lib/auth/browser-storage.ts b/src/lib/auth/browser-storage.ts index cbade7c7..30171eea 100644 --- a/src/lib/auth/browser-storage.ts +++ b/src/lib/auth/browser-storage.ts @@ -4,3 +4,38 @@ */ export const REMEMBERED_USERNAME_KEY = 'wallet:rememberedUsername'; export const REMEMBERED_POSTING_KEY_KEY = 'wallet:rememberedPostingKey'; + +/** Normalize Steem account names for comparisons (matches LoginForm handling). */ +export function normalizeSteemUsername(raw: string): string { + return raw.trim().toLowerCase().replace(/^@+/, ''); +} + +/** Read remembered username from localStorage (client only). */ +export function getRememberedDeviceUsername(): string | null { + if (typeof window === 'undefined') return null; + try { + const saved = localStorage.getItem(REMEMBERED_USERNAME_KEY); + if (!saved?.trim()) return null; + return normalizeSteemUsername(saved); + } catch { + return null; + } +} + +/** + * Whether this device/session may use balance actions on the given profile URL. + * True if the URL account is remembered on this device or the Redux session user matches. + */ +export function canManageBalanceForPageUrl(params: { + urlUsername: string; + loggedInUser: string | null; + isAuthenticated: boolean; +}): boolean { + const { urlUsername, loggedInUser, isAuthenticated } = params; + if (!urlUsername?.trim()) return false; + const nu = normalizeSteemUsername(urlUsername); + const remembered = getRememberedDeviceUsername(); + if (remembered && remembered === nu) return true; + if (isAuthenticated && loggedInUser && normalizeSteemUsername(loggedInUser) === nu) return true; + return false; +} diff --git a/src/lib/steem/normalize-profile.ts b/src/lib/steem/normalize-profile.ts index 3c356570..e324a727 100644 --- a/src/lib/steem/normalize-profile.ts +++ b/src/lib/steem/normalize-profile.ts @@ -87,7 +87,7 @@ export function normalizeProfile(account: AccountWithMeta | null | undefined): N let name = pickString(profile.name); let about = pickString(profile.about); let location = pickString(profile.location); - let website = normalizeWebsite(pickString(profile.website)); + const website = normalizeWebsite(pickString(profile.website)); const profile_image = normalizeHttpsUrl(pickString(profile.profile_image)); const cover_image = normalizeHttpsUrl(pickString(profile.cover_image)); diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts index 52f616c2..b92f3745 100644 --- a/tests/e2e/smoke.spec.ts +++ b/tests/e2e/smoke.spec.ts @@ -3,12 +3,18 @@ import { test, expect } from '@playwright/test'; test.describe('Smoke', () => { test('home page loads and shows login form', async ({ page }) => { await page.goto('/'); - await expect(page.getByRole('button', { name: /sign in|login/i }).first()).toBeVisible({ timeout: 15_000 }); + // LoginForm submit (locale-aware); not "password" — secret field is labeled Private Key. + await expect( + page.getByRole('button', { name: /sign in|log in|login|登录|iniciar sesión/i }).first() + ).toBeVisible({ timeout: 15_000 }); }); - test('login page has username and password inputs', async ({ page }) => { + test('login page has username and private key inputs', async ({ page }) => { await page.goto('/login'); - await expect(page.getByRole('textbox', { name: /username/i }).first()).toBeVisible({ timeout: 15_000 }); - await expect(page.getByLabel(/password/i).first()).toBeVisible({ timeout: 5_000 }); + await expect( + page.getByRole('textbox', { name: /username|用户|usuario/i }).first() + ).toBeVisible({ timeout: 15_000 }); + // Label comes from auth.privateKey ("Private Key" / "私钥" / "Clave privada"), not "password". + await expect(page.locator('#password')).toBeVisible({ timeout: 5_000 }); }); });