terry-wallet-login #15
@@ -13,3 +13,7 @@ VITE_ADMIN_UI_PREFIX=
|
||||
# Use mock Post data (Telegram-style resource stream) only when explicitly enabled.
|
||||
# Default production/staging behavior should hit the real /api/posts API.
|
||||
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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rainbow-me/rainbowkit": "^2.2.11",
|
||||
"@tanstack/react-query": "^5.100.14",
|
||||
"framer-motion": "^11.18.2",
|
||||
"lucide-react": "^0.460.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^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": {
|
||||
"@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 { ToastProvider } from "./components/Toast";
|
||||
import { SaveToAlbumGuideProvider } from "./components/SaveToAlbumGuide";
|
||||
import { RainbowWalletProvider } from "./wallet/RainbowWalletProvider";
|
||||
import { WalletProvider } from "./wallet/WalletProvider";
|
||||
import { PublicLayout } from "./layouts/PublicLayout";
|
||||
import { LocalizedHomePage } from "./pages/LocalizedHome";
|
||||
import { Browse } from "./pages/Browse";
|
||||
@@ -28,89 +30,107 @@ export default function App() {
|
||||
<I18nProvider>
|
||||
<MotionProvider>
|
||||
<ToastProvider>
|
||||
<SaveToAlbumGuideProvider>
|
||||
<AdminRouterModeProvider value="absolute">
|
||||
<ImageLightboxProvider>
|
||||
<VideoPlayerProvider>
|
||||
<PageTitleProvider>
|
||||
<BrowserRouter>
|
||||
<ScrollToTop />
|
||||
<Routes>
|
||||
<Route element={<PublicLayout />}>
|
||||
{/* English (root, no prefix) */}
|
||||
<Route
|
||||
path="/"
|
||||
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}>
|
||||
<RainbowWalletProvider>
|
||||
<WalletProvider>
|
||||
<SaveToAlbumGuideProvider>
|
||||
<AdminRouterModeProvider value="absolute">
|
||||
<ImageLightboxProvider>
|
||||
<VideoPlayerProvider>
|
||||
<PageTitleProvider>
|
||||
<BrowserRouter>
|
||||
<ScrollToTop />
|
||||
<Routes>
|
||||
<Route element={<PublicLayout />}>
|
||||
{/* English (root, no prefix) */}
|
||||
<Route
|
||||
index
|
||||
element={
|
||||
<LocalizedHomePage targetLang={route.lang} />
|
||||
}
|
||||
path="/"
|
||||
element={<LocalizedHomePage targetLang="en" />}
|
||||
/>
|
||||
<Route path="browse" element={<Browse />} />
|
||||
<Route path="/browse" element={<Browse />} />
|
||||
<Route
|
||||
path="categories"
|
||||
path="/categories"
|
||||
element={<CategoriesPage />}
|
||||
/>
|
||||
<Route
|
||||
path="official-recommendations"
|
||||
path="/official-recommendations"
|
||||
element={<OfficialRecommendationsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="category/:slug"
|
||||
path="/category/:slug"
|
||||
element={<CategoryPage />}
|
||||
/>
|
||||
<Route path="search" element={<SearchPage />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route
|
||||
path="resource/:id"
|
||||
path="/resource/:id"
|
||||
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>
|
||||
|
||||
{adminEnabled ? (
|
||||
AdminRouteTree()
|
||||
) : (
|
||||
<Route
|
||||
path={`${adminUiPrefix}/*`}
|
||||
element={<Navigate to="/" replace />}
|
||||
/>
|
||||
)}
|
||||
{adminEnabled ? (
|
||||
AdminRouteTree()
|
||||
) : (
|
||||
<Route
|
||||
path={`${adminUiPrefix}/*`}
|
||||
element={<Navigate to="/" replace />}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</PageTitleProvider>
|
||||
</VideoPlayerProvider>
|
||||
</ImageLightboxProvider>
|
||||
</AdminRouterModeProvider>
|
||||
</SaveToAlbumGuideProvider>
|
||||
<Route
|
||||
path="*"
|
||||
element={<Navigate to="/" replace />}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</PageTitleProvider>
|
||||
</VideoPlayerProvider>
|
||||
</ImageLightboxProvider>
|
||||
</AdminRouterModeProvider>
|
||||
</SaveToAlbumGuideProvider>
|
||||
</WalletProvider>
|
||||
</RainbowWalletProvider>
|
||||
</ToastProvider>
|
||||
</MotionProvider>
|
||||
</I18nProvider>
|
||||
|
||||
@@ -143,6 +143,9 @@ export const enDict: Dict = {
|
||||
favoritesComingSoon: "Coming Soon",
|
||||
favoritesComingSoonDesc:
|
||||
"Sign-in and favorites are in development. Stay tuned.",
|
||||
walletLoginSuccess: "Wallet connected",
|
||||
walletLoginFailed: "Wallet login failed",
|
||||
walletDisconnected: "Wallet disconnected",
|
||||
featureUnavailable: "Not available yet",
|
||||
featureUnavailableDesc: "This feature is not available yet.",
|
||||
confirm: "Got it",
|
||||
|
||||
@@ -143,6 +143,9 @@ export const idDict: Dict = {
|
||||
favoritesComingSoon: "Segera Hadir",
|
||||
favoritesComingSoonDesc:
|
||||
"Fitur masuk dan favorit sedang dikembangkan. Nantikan.",
|
||||
walletLoginSuccess: "Dompet terhubung",
|
||||
walletLoginFailed: "Login dompet gagal",
|
||||
walletDisconnected: "Dompet terputus",
|
||||
featureUnavailable: "Belum tersedia",
|
||||
featureUnavailableDesc: "Fitur ini belum tersedia.",
|
||||
confirm: "Mengerti",
|
||||
|
||||
@@ -143,6 +143,9 @@ export const jaDict: Dict = {
|
||||
favorites: "お気に入り",
|
||||
favoritesComingSoon: "近日公開",
|
||||
favoritesComingSoonDesc: "ログインとお気に入り機能は開発中です。お楽しみに。",
|
||||
walletLoginSuccess: "ウォレットを接続しました",
|
||||
walletLoginFailed: "ウォレットログインに失敗しました",
|
||||
walletDisconnected: "ウォレットを切断しました",
|
||||
featureUnavailable: "未公開",
|
||||
featureUnavailableDesc: "この機能はまだご利用いただけません。",
|
||||
confirm: "了解",
|
||||
|
||||
@@ -143,6 +143,9 @@ export const koDict: Dict = {
|
||||
favoritesComingSoon: "출시 예정",
|
||||
favoritesComingSoonDesc:
|
||||
"로그인과 즐겨찾기 기능을 개발 중입니다. 많은 기대 부탁드립니다.",
|
||||
walletLoginSuccess: "지갑이 연결되었습니다",
|
||||
walletLoginFailed: "지갑 로그인에 실패했습니다",
|
||||
walletDisconnected: "지갑 연결이 해제되었습니다",
|
||||
featureUnavailable: "준비 중",
|
||||
featureUnavailableDesc: "이 기능은 아직 사용할 수 없습니다.",
|
||||
confirm: "확인",
|
||||
|
||||
@@ -143,6 +143,9 @@ export const msDict: Dict = {
|
||||
favoritesComingSoon: "Akan Hadir",
|
||||
favoritesComingSoonDesc:
|
||||
"Ciri log masuk dan kegemaran sedang dibangunkan. Nantikan.",
|
||||
walletLoginSuccess: "Dompet disambungkan",
|
||||
walletLoginFailed: "Log masuk dompet gagal",
|
||||
walletDisconnected: "Dompet diputuskan",
|
||||
featureUnavailable: "Belum tersedia",
|
||||
featureUnavailableDesc: "Ciri ini belum tersedia.",
|
||||
confirm: "Faham",
|
||||
|
||||
@@ -143,6 +143,9 @@ export const viDict: Dict = {
|
||||
favoritesComingSoon: "Sắp ra mắt",
|
||||
favoritesComingSoonDesc:
|
||||
"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",
|
||||
featureUnavailableDesc: "Tính năng này hiện chưa khả dụng.",
|
||||
confirm: "Đã hiểu",
|
||||
|
||||
@@ -140,6 +140,9 @@ export const zhDict: Dict = {
|
||||
favorites: "我的收藏",
|
||||
favoritesComingSoon: "功能即将推出",
|
||||
favoritesComingSoonDesc: "登入与收藏功能开发中,敬请期待。",
|
||||
walletLoginSuccess: "钱包已连接",
|
||||
walletLoginFailed: "钱包登录失败",
|
||||
walletDisconnected: "钱包已断开",
|
||||
featureUnavailable: "未开放",
|
||||
featureUnavailableDesc: "该功能暂未开放。",
|
||||
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_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. */
|
||||
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