terry-wallet-login #15
@@ -13,3 +13,7 @@ VITE_ADMIN_UI_PREFIX=
|
|||||||
# Use mock Post data (Telegram-style resource stream) only when explicitly enabled.
|
# Use mock Post data (Telegram-style resource stream) only when explicitly enabled.
|
||||||
# Default production/staging behavior should hit the real /api/posts API.
|
# Default production/staging behavior should hit the real /api/posts API.
|
||||||
VITE_USE_MOCK_POSTS=false
|
VITE_USE_MOCK_POSTS=false
|
||||||
|
|
||||||
|
# Reown/WalletConnect project ID used by RainbowKit fallback QR login
|
||||||
|
# for MetaMask/imToken. TokenPocket QR does not depend on this.
|
||||||
|
VITE_WALLETCONNECT_PROJECT_ID=
|
||||||
|
|||||||
7833
package-lock.json
generated
7833
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,11 +13,16 @@
|
|||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@rainbow-me/rainbowkit": "^2.2.11",
|
||||||
|
"@tanstack/react-query": "^5.100.14",
|
||||||
"framer-motion": "^11.18.2",
|
"framer-motion": "^11.18.2",
|
||||||
"lucide-react": "^0.460.0",
|
"lucide-react": "^0.460.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.28.0"
|
"react-router-dom": "^6.28.0",
|
||||||
|
"viem": "^2.52.0",
|
||||||
|
"wagmi": "^2.19.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
|||||||
152
src/App.tsx
152
src/App.tsx
@@ -3,6 +3,8 @@ import { I18nProvider } from "./i18n";
|
|||||||
import { MotionProvider } from "./motion";
|
import { MotionProvider } from "./motion";
|
||||||
import { ToastProvider } from "./components/Toast";
|
import { ToastProvider } from "./components/Toast";
|
||||||
import { SaveToAlbumGuideProvider } from "./components/SaveToAlbumGuide";
|
import { SaveToAlbumGuideProvider } from "./components/SaveToAlbumGuide";
|
||||||
|
import { RainbowWalletProvider } from "./wallet/RainbowWalletProvider";
|
||||||
|
import { WalletProvider } from "./wallet/WalletProvider";
|
||||||
import { PublicLayout } from "./layouts/PublicLayout";
|
import { PublicLayout } from "./layouts/PublicLayout";
|
||||||
import { LocalizedHomePage } from "./pages/LocalizedHome";
|
import { LocalizedHomePage } from "./pages/LocalizedHome";
|
||||||
import { Browse } from "./pages/Browse";
|
import { Browse } from "./pages/Browse";
|
||||||
@@ -28,89 +30,107 @@ export default function App() {
|
|||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<MotionProvider>
|
<MotionProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<SaveToAlbumGuideProvider>
|
<RainbowWalletProvider>
|
||||||
<AdminRouterModeProvider value="absolute">
|
<WalletProvider>
|
||||||
<ImageLightboxProvider>
|
<SaveToAlbumGuideProvider>
|
||||||
<VideoPlayerProvider>
|
<AdminRouterModeProvider value="absolute">
|
||||||
<PageTitleProvider>
|
<ImageLightboxProvider>
|
||||||
<BrowserRouter>
|
<VideoPlayerProvider>
|
||||||
<ScrollToTop />
|
<PageTitleProvider>
|
||||||
<Routes>
|
<BrowserRouter>
|
||||||
<Route element={<PublicLayout />}>
|
<ScrollToTop />
|
||||||
{/* English (root, no prefix) */}
|
<Routes>
|
||||||
<Route
|
<Route element={<PublicLayout />}>
|
||||||
path="/"
|
{/* English (root, no prefix) */}
|
||||||
element={<LocalizedHomePage targetLang="en" />}
|
|
||||||
/>
|
|
||||||
<Route path="/browse" element={<Browse />} />
|
|
||||||
<Route
|
|
||||||
path="/categories"
|
|
||||||
element={<CategoriesPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/official-recommendations"
|
|
||||||
element={<OfficialRecommendationsPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/category/:slug"
|
|
||||||
element={<CategoryPage />}
|
|
||||||
/>
|
|
||||||
<Route path="/search" element={<SearchPage />} />
|
|
||||||
<Route
|
|
||||||
path="/resource/:id"
|
|
||||||
element={<PostRedirect />}
|
|
||||||
/>
|
|
||||||
<Route path="/favorites" element={<Favorites />} />
|
|
||||||
|
|
||||||
{/* Each non-English language gets its own nested tree. */}
|
|
||||||
{localizedHomeRoutes.map((route) => (
|
|
||||||
<Route key={route.path} path={route.path}>
|
|
||||||
<Route
|
<Route
|
||||||
index
|
path="/"
|
||||||
element={
|
element={<LocalizedHomePage targetLang="en" />}
|
||||||
<LocalizedHomePage targetLang={route.lang} />
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Route path="browse" element={<Browse />} />
|
<Route path="/browse" element={<Browse />} />
|
||||||
<Route
|
<Route
|
||||||
path="categories"
|
path="/categories"
|
||||||
element={<CategoriesPage />}
|
element={<CategoriesPage />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="official-recommendations"
|
path="/official-recommendations"
|
||||||
element={<OfficialRecommendationsPage />}
|
element={<OfficialRecommendationsPage />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="category/:slug"
|
path="/category/:slug"
|
||||||
element={<CategoryPage />}
|
element={<CategoryPage />}
|
||||||
/>
|
/>
|
||||||
<Route path="search" element={<SearchPage />} />
|
<Route path="/search" element={<SearchPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="resource/:id"
|
path="/resource/:id"
|
||||||
element={<PostRedirect />}
|
element={<PostRedirect />}
|
||||||
/>
|
/>
|
||||||
<Route path="favorites" element={<Favorites />} />
|
<Route
|
||||||
|
path="/favorites"
|
||||||
|
element={<Favorites />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Each non-English language gets its own nested tree. */}
|
||||||
|
{localizedHomeRoutes.map((route) => (
|
||||||
|
<Route key={route.path} path={route.path}>
|
||||||
|
<Route
|
||||||
|
index
|
||||||
|
element={
|
||||||
|
<LocalizedHomePage
|
||||||
|
targetLang={route.lang}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="browse" element={<Browse />} />
|
||||||
|
<Route
|
||||||
|
path="categories"
|
||||||
|
element={<CategoriesPage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="official-recommendations"
|
||||||
|
element={<OfficialRecommendationsPage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="category/:slug"
|
||||||
|
element={<CategoryPage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="search"
|
||||||
|
element={<SearchPage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="resource/:id"
|
||||||
|
element={<PostRedirect />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="favorites"
|
||||||
|
element={<Favorites />}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
))}
|
||||||
</Route>
|
</Route>
|
||||||
))}
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
{adminEnabled ? (
|
{adminEnabled ? (
|
||||||
AdminRouteTree()
|
AdminRouteTree()
|
||||||
) : (
|
) : (
|
||||||
<Route
|
<Route
|
||||||
path={`${adminUiPrefix}/*`}
|
path={`${adminUiPrefix}/*`}
|
||||||
element={<Navigate to="/" replace />}
|
element={<Navigate to="/" replace />}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route
|
||||||
</Routes>
|
path="*"
|
||||||
</BrowserRouter>
|
element={<Navigate to="/" replace />}
|
||||||
</PageTitleProvider>
|
/>
|
||||||
</VideoPlayerProvider>
|
</Routes>
|
||||||
</ImageLightboxProvider>
|
</BrowserRouter>
|
||||||
</AdminRouterModeProvider>
|
</PageTitleProvider>
|
||||||
</SaveToAlbumGuideProvider>
|
</VideoPlayerProvider>
|
||||||
|
</ImageLightboxProvider>
|
||||||
|
</AdminRouterModeProvider>
|
||||||
|
</SaveToAlbumGuideProvider>
|
||||||
|
</WalletProvider>
|
||||||
|
</RainbowWalletProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</MotionProvider>
|
</MotionProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
|
|||||||
@@ -143,6 +143,9 @@ export const enDict: Dict = {
|
|||||||
favoritesComingSoon: "Coming Soon",
|
favoritesComingSoon: "Coming Soon",
|
||||||
favoritesComingSoonDesc:
|
favoritesComingSoonDesc:
|
||||||
"Sign-in and favorites are in development. Stay tuned.",
|
"Sign-in and favorites are in development. Stay tuned.",
|
||||||
|
walletLoginSuccess: "Wallet connected",
|
||||||
|
walletLoginFailed: "Wallet login failed",
|
||||||
|
walletDisconnected: "Wallet disconnected",
|
||||||
featureUnavailable: "Not available yet",
|
featureUnavailable: "Not available yet",
|
||||||
featureUnavailableDesc: "This feature is not available yet.",
|
featureUnavailableDesc: "This feature is not available yet.",
|
||||||
confirm: "Got it",
|
confirm: "Got it",
|
||||||
|
|||||||
@@ -143,6 +143,9 @@ export const idDict: Dict = {
|
|||||||
favoritesComingSoon: "Segera Hadir",
|
favoritesComingSoon: "Segera Hadir",
|
||||||
favoritesComingSoonDesc:
|
favoritesComingSoonDesc:
|
||||||
"Fitur masuk dan favorit sedang dikembangkan. Nantikan.",
|
"Fitur masuk dan favorit sedang dikembangkan. Nantikan.",
|
||||||
|
walletLoginSuccess: "Dompet terhubung",
|
||||||
|
walletLoginFailed: "Login dompet gagal",
|
||||||
|
walletDisconnected: "Dompet terputus",
|
||||||
featureUnavailable: "Belum tersedia",
|
featureUnavailable: "Belum tersedia",
|
||||||
featureUnavailableDesc: "Fitur ini belum tersedia.",
|
featureUnavailableDesc: "Fitur ini belum tersedia.",
|
||||||
confirm: "Mengerti",
|
confirm: "Mengerti",
|
||||||
|
|||||||
@@ -143,6 +143,9 @@ export const jaDict: Dict = {
|
|||||||
favorites: "お気に入り",
|
favorites: "お気に入り",
|
||||||
favoritesComingSoon: "近日公開",
|
favoritesComingSoon: "近日公開",
|
||||||
favoritesComingSoonDesc: "ログインとお気に入り機能は開発中です。お楽しみに。",
|
favoritesComingSoonDesc: "ログインとお気に入り機能は開発中です。お楽しみに。",
|
||||||
|
walletLoginSuccess: "ウォレットを接続しました",
|
||||||
|
walletLoginFailed: "ウォレットログインに失敗しました",
|
||||||
|
walletDisconnected: "ウォレットを切断しました",
|
||||||
featureUnavailable: "未公開",
|
featureUnavailable: "未公開",
|
||||||
featureUnavailableDesc: "この機能はまだご利用いただけません。",
|
featureUnavailableDesc: "この機能はまだご利用いただけません。",
|
||||||
confirm: "了解",
|
confirm: "了解",
|
||||||
|
|||||||
@@ -143,6 +143,9 @@ export const koDict: Dict = {
|
|||||||
favoritesComingSoon: "출시 예정",
|
favoritesComingSoon: "출시 예정",
|
||||||
favoritesComingSoonDesc:
|
favoritesComingSoonDesc:
|
||||||
"로그인과 즐겨찾기 기능을 개발 중입니다. 많은 기대 부탁드립니다.",
|
"로그인과 즐겨찾기 기능을 개발 중입니다. 많은 기대 부탁드립니다.",
|
||||||
|
walletLoginSuccess: "지갑이 연결되었습니다",
|
||||||
|
walletLoginFailed: "지갑 로그인에 실패했습니다",
|
||||||
|
walletDisconnected: "지갑 연결이 해제되었습니다",
|
||||||
featureUnavailable: "준비 중",
|
featureUnavailable: "준비 중",
|
||||||
featureUnavailableDesc: "이 기능은 아직 사용할 수 없습니다.",
|
featureUnavailableDesc: "이 기능은 아직 사용할 수 없습니다.",
|
||||||
confirm: "확인",
|
confirm: "확인",
|
||||||
|
|||||||
@@ -143,6 +143,9 @@ export const msDict: Dict = {
|
|||||||
favoritesComingSoon: "Akan Hadir",
|
favoritesComingSoon: "Akan Hadir",
|
||||||
favoritesComingSoonDesc:
|
favoritesComingSoonDesc:
|
||||||
"Ciri log masuk dan kegemaran sedang dibangunkan. Nantikan.",
|
"Ciri log masuk dan kegemaran sedang dibangunkan. Nantikan.",
|
||||||
|
walletLoginSuccess: "Dompet disambungkan",
|
||||||
|
walletLoginFailed: "Log masuk dompet gagal",
|
||||||
|
walletDisconnected: "Dompet diputuskan",
|
||||||
featureUnavailable: "Belum tersedia",
|
featureUnavailable: "Belum tersedia",
|
||||||
featureUnavailableDesc: "Ciri ini belum tersedia.",
|
featureUnavailableDesc: "Ciri ini belum tersedia.",
|
||||||
confirm: "Faham",
|
confirm: "Faham",
|
||||||
|
|||||||
@@ -143,6 +143,9 @@ export const viDict: Dict = {
|
|||||||
favoritesComingSoon: "Sắp ra mắt",
|
favoritesComingSoon: "Sắp ra mắt",
|
||||||
favoritesComingSoonDesc:
|
favoritesComingSoonDesc:
|
||||||
"Tính năng đăng nhập và yêu thích đang phát triển. Hãy chờ đón.",
|
"Tính năng đăng nhập và yêu thích đang phát triển. Hãy chờ đón.",
|
||||||
|
walletLoginSuccess: "Đã kết nối ví",
|
||||||
|
walletLoginFailed: "Đăng nhập ví thất bại",
|
||||||
|
walletDisconnected: "Đã ngắt kết nối ví",
|
||||||
featureUnavailable: "Chưa khả dụng",
|
featureUnavailable: "Chưa khả dụng",
|
||||||
featureUnavailableDesc: "Tính năng này hiện chưa khả dụng.",
|
featureUnavailableDesc: "Tính năng này hiện chưa khả dụng.",
|
||||||
confirm: "Đã hiểu",
|
confirm: "Đã hiểu",
|
||||||
|
|||||||
@@ -140,6 +140,9 @@ export const zhDict: Dict = {
|
|||||||
favorites: "我的收藏",
|
favorites: "我的收藏",
|
||||||
favoritesComingSoon: "功能即将推出",
|
favoritesComingSoon: "功能即将推出",
|
||||||
favoritesComingSoonDesc: "登入与收藏功能开发中,敬请期待。",
|
favoritesComingSoonDesc: "登入与收藏功能开发中,敬请期待。",
|
||||||
|
walletLoginSuccess: "钱包已连接",
|
||||||
|
walletLoginFailed: "钱包登录失败",
|
||||||
|
walletDisconnected: "钱包已断开",
|
||||||
featureUnavailable: "未开放",
|
featureUnavailable: "未开放",
|
||||||
featureUnavailableDesc: "该功能暂未开放。",
|
featureUnavailableDesc: "该功能暂未开放。",
|
||||||
confirm: "知道了",
|
confirm: "知道了",
|
||||||
|
|||||||
1
src/vite-env.d.ts
vendored
1
src/vite-env.d.ts
vendored
@@ -4,6 +4,7 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_API_URL: string;
|
readonly VITE_API_URL: string;
|
||||||
readonly VITE_API_PREFIX?: string;
|
readonly VITE_API_PREFIX?: string;
|
||||||
readonly VITE_ADMIN_UI_PREFIX?: string;
|
readonly VITE_ADMIN_UI_PREFIX?: string;
|
||||||
|
readonly VITE_WALLETCONNECT_PROJECT_ID?: string;
|
||||||
/** When `"true"`, bundle admin UI only (no public pages); use with `VITE_ADMIN_UI_PREFIX` or default secret prefix. */
|
/** When `"true"`, bundle admin UI only (no public pages); use with `VITE_ADMIN_UI_PREFIX` or default secret prefix. */
|
||||||
readonly VITE_ADMIN_ONLY?: string;
|
readonly VITE_ADMIN_ONLY?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
68
src/wallet/RainbowWalletProvider.tsx
Normal file
68
src/wallet/RainbowWalletProvider.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import "@rainbow-me/rainbowkit/styles.css";
|
||||||
|
|
||||||
|
import {
|
||||||
|
RainbowKitProvider,
|
||||||
|
connectorsForWallets,
|
||||||
|
darkTheme,
|
||||||
|
} from "@rainbow-me/rainbowkit";
|
||||||
|
import {
|
||||||
|
imTokenWallet,
|
||||||
|
metaMaskWallet,
|
||||||
|
tokenPocketWallet,
|
||||||
|
} from "@rainbow-me/rainbowkit/wallets";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { http, createConfig, WagmiProvider } from "wagmi";
|
||||||
|
import { mainnet } from "wagmi/chains";
|
||||||
|
|
||||||
|
const projectId =
|
||||||
|
import.meta.env.VITE_WALLETCONNECT_PROJECT_ID || "ark-library-dev-only";
|
||||||
|
|
||||||
|
const connectors = connectorsForWallets(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
groupName: "ARK Library",
|
||||||
|
wallets: [metaMaskWallet, imTokenWallet, tokenPocketWallet],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
appName: "ARK Library",
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const wagmiConfig = createConfig({
|
||||||
|
chains: [mainnet],
|
||||||
|
connectors,
|
||||||
|
ssr: false,
|
||||||
|
transports: {
|
||||||
|
[mainnet.id]: http(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function RainbowWalletProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [queryClient] = useState(() => new QueryClient());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WagmiProvider config={wagmiConfig}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RainbowKitProvider
|
||||||
|
modalSize="compact"
|
||||||
|
theme={darkTheme({
|
||||||
|
accentColor: "#d7b46a",
|
||||||
|
accentColorForeground: "#08070c",
|
||||||
|
borderRadius: "large",
|
||||||
|
fontStack: "system",
|
||||||
|
overlayBlur: "small",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</RainbowKitProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</WagmiProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasWalletConnectProjectId(): boolean {
|
||||||
|
return Boolean(import.meta.env.VITE_WALLETCONNECT_PROJECT_ID);
|
||||||
|
}
|
||||||
136
src/wallet/WalletProvider.tsx
Normal file
136
src/wallet/WalletProvider.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { useToast } from "../components/Toast";
|
||||||
|
import { useI18n } from "../i18n";
|
||||||
|
import { fetchWalletMe } from "./api";
|
||||||
|
import { signInWithInjectedWallet } from "./injected";
|
||||||
|
import { clearWalletToken, readWalletToken, writeWalletToken } from "./token";
|
||||||
|
|
||||||
|
type WalletStatus = "loading" | "loggedOut" | "loggedIn";
|
||||||
|
|
||||||
|
type WalletContextValue = {
|
||||||
|
address: string | null;
|
||||||
|
token: string | null;
|
||||||
|
status: WalletStatus;
|
||||||
|
loginModalOpen: boolean;
|
||||||
|
openLoginModal: () => void;
|
||||||
|
closeLoginModal: () => void;
|
||||||
|
signInInjected: () => Promise<void>;
|
||||||
|
completeLogin: (token: string, wallet: string) => void;
|
||||||
|
logout: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WalletContext = createContext<WalletContextValue | null>(null);
|
||||||
|
|
||||||
|
export function shortenAddress(address: string): string {
|
||||||
|
if (address.length <= 12) return address;
|
||||||
|
return `${address.slice(0, 6)}...${address.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WalletProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [token, setToken] = useState<string | null>(() => readWalletToken());
|
||||||
|
const [address, setAddress] = useState<string | null>(null);
|
||||||
|
const [status, setStatus] = useState<WalletStatus>(
|
||||||
|
token ? "loading" : "loggedOut",
|
||||||
|
);
|
||||||
|
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
if (!token) {
|
||||||
|
setStatus("loggedOut");
|
||||||
|
setAddress(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("loading");
|
||||||
|
fetchWalletMe(token)
|
||||||
|
.then((me) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setAddress(me.wallet);
|
||||||
|
setStatus("loggedIn");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
clearWalletToken();
|
||||||
|
setToken(null);
|
||||||
|
setAddress(null);
|
||||||
|
setStatus("loggedOut");
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const completeLogin = useCallback((nextToken: string, wallet: string) => {
|
||||||
|
writeWalletToken(nextToken);
|
||||||
|
setToken(nextToken);
|
||||||
|
setAddress(wallet);
|
||||||
|
setStatus("loggedIn");
|
||||||
|
setLoginModalOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const signInInjected = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await signInWithInjectedWallet();
|
||||||
|
completeLogin(res.token, res.wallet);
|
||||||
|
showToast(t("walletLoginSuccess"));
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : t("walletLoginFailed");
|
||||||
|
showToast(message || t("walletLoginFailed"), "error");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [completeLogin, showToast, t]);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
clearWalletToken();
|
||||||
|
setToken(null);
|
||||||
|
setAddress(null);
|
||||||
|
setStatus("loggedOut");
|
||||||
|
showToast(t("walletDisconnected"));
|
||||||
|
}, [showToast, t]);
|
||||||
|
|
||||||
|
const value = useMemo<WalletContextValue>(
|
||||||
|
() => ({
|
||||||
|
address,
|
||||||
|
token,
|
||||||
|
status,
|
||||||
|
loginModalOpen,
|
||||||
|
openLoginModal: () => setLoginModalOpen(true),
|
||||||
|
closeLoginModal: () => setLoginModalOpen(false),
|
||||||
|
signInInjected,
|
||||||
|
completeLogin,
|
||||||
|
logout,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
address,
|
||||||
|
completeLogin,
|
||||||
|
loginModalOpen,
|
||||||
|
logout,
|
||||||
|
signInInjected,
|
||||||
|
status,
|
||||||
|
token,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WalletContext.Provider value={value}>{children}</WalletContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWallet() {
|
||||||
|
const ctx = useContext(WalletContext);
|
||||||
|
if (!ctx) throw new Error("useWallet must be used within WalletProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
72
src/wallet/api.ts
Normal file
72
src/wallet/api.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { apiBase, getJSONAuth, postJSON } from "../api";
|
||||||
|
|
||||||
|
export type WalletNonceResponse = {
|
||||||
|
nonce: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WalletVerifyResponse = {
|
||||||
|
token: string;
|
||||||
|
wallet: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WalletMeResponse = {
|
||||||
|
wallet: string;
|
||||||
|
role: "user";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TokenPocketLoginRequest = {
|
||||||
|
actionId: string;
|
||||||
|
nonce: string;
|
||||||
|
message: string;
|
||||||
|
qrUrl: string;
|
||||||
|
expiresAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TokenPocketLoginResult =
|
||||||
|
| {
|
||||||
|
status: "pending" | "expired" | "failed";
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "completed";
|
||||||
|
address: string;
|
||||||
|
message: string;
|
||||||
|
signature: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function requestWalletNonce(
|
||||||
|
address: string,
|
||||||
|
): Promise<WalletNonceResponse> {
|
||||||
|
return postJSON<WalletNonceResponse>("/api/auth/wallet/nonce", { address });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyWalletSignature(params: {
|
||||||
|
address: string;
|
||||||
|
message: string;
|
||||||
|
signature: string;
|
||||||
|
}): Promise<WalletVerifyResponse> {
|
||||||
|
return postJSON<WalletVerifyResponse>("/api/auth/wallet/verify", params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchWalletMe(token: string): Promise<WalletMeResponse> {
|
||||||
|
return getJSONAuth<WalletMeResponse>("/api/auth/wallet/me", token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTokenPocketLoginRequest(): Promise<TokenPocketLoginRequest> {
|
||||||
|
return postJSON<TokenPocketLoginRequest>(
|
||||||
|
"/api/auth/wallet/tp-login-request",
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTokenPocketLoginResult(
|
||||||
|
actionId: string,
|
||||||
|
): Promise<TokenPocketLoginResult> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${apiBase}/api/auth/wallet/tp-result?actionId=${encodeURIComponent(actionId)}`,
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
return res.json() as Promise<TokenPocketLoginResult>;
|
||||||
|
}
|
||||||
39
src/wallet/injected.ts
Normal file
39
src/wallet/injected.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { requestWalletNonce, verifyWalletSignature } from "./api";
|
||||||
|
|
||||||
|
export type EthereumProvider = {
|
||||||
|
request: <T = unknown>(args: {
|
||||||
|
method: string;
|
||||||
|
params?: unknown[];
|
||||||
|
}) => Promise<T>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getInjectedEthereum(): EthereumProvider | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
const maybeWindow = window as typeof window & { ethereum?: EthereumProvider };
|
||||||
|
return maybeWindow.ethereum ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signInWithInjectedWallet(): Promise<{
|
||||||
|
token: string;
|
||||||
|
wallet: string;
|
||||||
|
}> {
|
||||||
|
const ethereum = getInjectedEthereum();
|
||||||
|
if (!ethereum) throw new Error("No injected wallet found");
|
||||||
|
|
||||||
|
const accounts = await ethereum.request<string[]>({
|
||||||
|
method: "eth_requestAccounts",
|
||||||
|
});
|
||||||
|
const address = accounts[0];
|
||||||
|
if (!address) throw new Error("No wallet account returned");
|
||||||
|
|
||||||
|
const nonce = await requestWalletNonce(address);
|
||||||
|
const signature = await ethereum.request<string>({
|
||||||
|
method: "personal_sign",
|
||||||
|
params: [nonce.message, address],
|
||||||
|
});
|
||||||
|
return verifyWalletSignature({
|
||||||
|
address,
|
||||||
|
message: nonce.message,
|
||||||
|
signature,
|
||||||
|
});
|
||||||
|
}
|
||||||
28
src/wallet/token.ts
Normal file
28
src/wallet/token.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
const walletTokenKey = "ark-wallet-token:v1";
|
||||||
|
|
||||||
|
export function readWalletToken(): string | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(walletTokenKey);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeWalletToken(token: string): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(walletTokenKey, token);
|
||||||
|
} catch {
|
||||||
|
// Ignore storage failures; session will only live in memory.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearWalletToken(): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(walletTokenKey);
|
||||||
|
} catch {
|
||||||
|
// Ignore storage failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user