terry-wallet-login #15
@@ -51,9 +51,14 @@ export function WalletLoginModal() {
|
|||||||
wc.reset();
|
wc.reset();
|
||||||
};
|
};
|
||||||
|
|
||||||
const startWalletLogin = (kind: WalletKind) => {
|
const selectWallet = (kind: WalletKind) => {
|
||||||
setSelected(kind);
|
setSelected(kind);
|
||||||
void wc.start(kind);
|
wc.reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const startWalletLogin = (kind: WalletKind, mode: "deeplink" | "qr") => {
|
||||||
|
setSelected(kind);
|
||||||
|
void wc.start(kind, mode);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -92,34 +97,63 @@ export function WalletLoginModal() {
|
|||||||
const connecting = active && wc.state === "connecting";
|
const connecting = active && wc.state === "connecting";
|
||||||
const signing = active && wc.state === "signing";
|
const signing = active && wc.state === "signing";
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={kind}
|
key={kind}
|
||||||
type="button"
|
className={`rounded-2xl border p-3 transition ${
|
||||||
onClick={() => startWalletLogin(kind)}
|
|
||||||
disabled={!wc.available || busy}
|
|
||||||
className={`flex items-center gap-3 rounded-2xl border px-4 py-4 text-left transition ${
|
|
||||||
active
|
active
|
||||||
? "border-ark-gold/60 bg-ark-gold/10"
|
? "border-ark-gold/60 bg-ark-gold/10"
|
||||||
: "border-white/10 bg-[#20202a] hover:border-ark-gold/50 hover:bg-ark-gold/10"
|
: "border-white/10 bg-[#20202a]"
|
||||||
} disabled:cursor-wait disabled:opacity-70`}
|
}`}
|
||||||
>
|
>
|
||||||
<WalletBrandIcon kind={kind} size={32} />
|
<button
|
||||||
<span className="min-w-0 flex-1">
|
type="button"
|
||||||
<span className="block text-base font-semibold text-neutral-100">
|
onClick={() =>
|
||||||
{walletName(kind)}
|
mobileDevice
|
||||||
|
? selectWallet(kind)
|
||||||
|
: startWalletLogin(kind, "qr")
|
||||||
|
}
|
||||||
|
disabled={!wc.available || busy}
|
||||||
|
className="flex w-full items-center gap-3 text-left disabled:cursor-wait disabled:opacity-70"
|
||||||
|
>
|
||||||
|
<WalletBrandIcon kind={kind} size={32} />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block text-base font-semibold text-neutral-100">
|
||||||
|
{walletName(kind)}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block text-xs leading-5 text-neutral-400">
|
||||||
|
{connecting
|
||||||
|
? t("walletConnecting")
|
||||||
|
: signing
|
||||||
|
? t("walletSigning")
|
||||||
|
: walletHint(kind)}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-1 block text-xs leading-5 text-neutral-400">
|
{connecting || signing ? (
|
||||||
{connecting
|
<LoaderCircle className="h-4 w-4 animate-spin text-ark-gold" />
|
||||||
? t("walletConnecting")
|
) : null}
|
||||||
: signing
|
</button>
|
||||||
? t("walletSigning")
|
|
||||||
: walletHint(kind)}
|
{mobileDevice && active ? (
|
||||||
</span>
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
</span>
|
<button
|
||||||
{connecting || signing ? (
|
type="button"
|
||||||
<LoaderCircle className="h-4 w-4 animate-spin text-ark-gold" />
|
onClick={() => startWalletLogin(kind, "deeplink")}
|
||||||
|
disabled={!wc.available || busy}
|
||||||
|
className="rounded-full bg-ark-gold px-3 py-2 text-sm font-bold text-black transition hover:bg-ark-gold2 disabled:cursor-wait disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{t("walletOpenWalletApp")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => startWalletLogin(kind, "qr")}
|
||||||
|
disabled={!wc.available || busy}
|
||||||
|
className="rounded-full border border-ark-gold/50 px-3 py-2 text-sm font-semibold text-ark-gold transition hover:bg-ark-gold/10 disabled:cursor-wait disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{t("walletQrLogin")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,18 @@ import { clearWalletToken, readWalletToken, writeWalletToken } from "./token";
|
|||||||
|
|
||||||
type WalletStatus = "loading" | "loggedOut" | "loggedIn";
|
type WalletStatus = "loading" | "loggedOut" | "loggedIn";
|
||||||
|
|
||||||
|
const localWalletTokenPrefix = "local-wallet:";
|
||||||
|
|
||||||
|
export function localWalletToken(wallet: string): string {
|
||||||
|
return `${localWalletTokenPrefix}${wallet}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walletFromLocalToken(token: string): string | null {
|
||||||
|
return token.startsWith(localWalletTokenPrefix)
|
||||||
|
? token.slice(localWalletTokenPrefix.length)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
type WalletContextValue = {
|
type WalletContextValue = {
|
||||||
address: string | null;
|
address: string | null;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
@@ -52,6 +64,13 @@ export function WalletProvider({ children }: { children: ReactNode }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const localWallet = walletFromLocalToken(token);
|
||||||
|
if (localWallet) {
|
||||||
|
setAddress(localWallet);
|
||||||
|
setStatus("loggedIn");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
fetchWalletMe(token)
|
fetchWalletMe(token)
|
||||||
.then((me) => {
|
.then((me) => {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useAccount, useConnect, useDisconnect, useSignMessage } from "wagmi";
|
import { useAccount, useConnect, useDisconnect } from "wagmi";
|
||||||
import { bsc } from "wagmi/chains";
|
import { bsc } from "wagmi/chains";
|
||||||
import { requestWalletNonce, verifyWalletSignature } from "./api";
|
|
||||||
import { hasWalletConnectProjectId } from "./RainbowWalletProvider";
|
import { hasWalletConnectProjectId } from "./RainbowWalletProvider";
|
||||||
import type { WalletKind } from "./injected";
|
import type { WalletKind } from "./injected";
|
||||||
import { useWallet } from "./WalletProvider";
|
import { localWalletToken, useWallet } from "./WalletProvider";
|
||||||
|
|
||||||
export type WalletConnectLoginState = "idle" | "connecting" | "signing";
|
export type WalletConnectLoginState = "idle" | "connecting" | "signing";
|
||||||
|
export type WalletConnectLoginMode = "deeplink" | "qr";
|
||||||
|
|
||||||
function isMobileDevice(): boolean {
|
function isMobileDevice(): boolean {
|
||||||
if (typeof navigator === "undefined") return false;
|
if (typeof navigator === "undefined") return false;
|
||||||
@@ -15,13 +15,28 @@ function isMobileDevice(): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function walletConnectDeeplink(
|
||||||
|
kind: WalletKind | undefined,
|
||||||
|
uri: string,
|
||||||
|
): string | null {
|
||||||
|
if (kind === "tokenPocket") {
|
||||||
|
return `tpoutside://wc?uri=${encodeURIComponent(uri)}`;
|
||||||
|
}
|
||||||
|
if (kind === "metaMask") {
|
||||||
|
return `https://metamask.app.link/wc?uri=${encodeURIComponent(uri)}`;
|
||||||
|
}
|
||||||
|
if (kind === "imToken") {
|
||||||
|
return `imtokenv2://wc?uri=${encodeURIComponent(uri)}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MetaMask / imToken QR fallback via RainbowKit + WalletConnect.
|
* MetaMask / imToken QR fallback via RainbowKit + WalletConnect.
|
||||||
*
|
*
|
||||||
* Flow: open the RainbowKit connect modal (WalletConnect QR) -> once an account
|
* Flow: connect through RainbowKit/Wagmi on BNB Chain -> once an account is
|
||||||
* is connected, request a nonce, sign it with `personal_sign` through wagmi,
|
* connected, complete a local frontend wallet session. No message signature,
|
||||||
* verify against the backend and complete our own JWT login. The wagmi/WC
|
* backend nonce, or verify call is required.
|
||||||
* session is only needed for the signature, so we disconnect right after.
|
|
||||||
*
|
*
|
||||||
* Entirely gated behind a real `VITE_WALLETCONNECT_PROJECT_ID`: when it is
|
* Entirely gated behind a real `VITE_WALLETCONNECT_PROJECT_ID`: when it is
|
||||||
* missing `available` is false and `start` is a no-op, so callers can hide or
|
* missing `available` is false and `start` is a no-op, so callers can hide or
|
||||||
@@ -31,7 +46,6 @@ export function useWalletConnectLogin() {
|
|||||||
const available = hasWalletConnectProjectId();
|
const available = hasWalletConnectProjectId();
|
||||||
const { completeLogin } = useWallet();
|
const { completeLogin } = useWallet();
|
||||||
const { address, isConnected } = useAccount();
|
const { address, isConnected } = useAccount();
|
||||||
const { signMessageAsync } = useSignMessage();
|
|
||||||
const { connectAsync, connectors } = useConnect();
|
const { connectAsync, connectors } = useConnect();
|
||||||
const { disconnect } = useDisconnect();
|
const { disconnect } = useDisconnect();
|
||||||
const [state, setState] = useState<WalletConnectLoginState>("idle");
|
const [state, setState] = useState<WalletConnectLoginState>("idle");
|
||||||
@@ -50,17 +64,24 @@ export function useWalletConnectLogin() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const start = useCallback(
|
const start = useCallback(
|
||||||
async (preferredWallet?: WalletKind) => {
|
async (
|
||||||
|
preferredWallet?: WalletKind,
|
||||||
|
mode: WalletConnectLoginMode = "qr",
|
||||||
|
) => {
|
||||||
if (!available) return;
|
if (!available) return;
|
||||||
setError("");
|
setError("");
|
||||||
setQrUri("");
|
setQrUri("");
|
||||||
pendingRef.current = true;
|
pendingRef.current = true;
|
||||||
setState("connecting");
|
setState("connecting");
|
||||||
|
|
||||||
|
// This modal is QR/WalletConnect-only. RainbowKit also exposes wallet-
|
||||||
|
// specific injected connectors (for example `tokenPocket`) when an
|
||||||
|
// extension is installed; using those here makes the click try the local
|
||||||
|
// browser extension and can fail with "wallet must has at least one
|
||||||
|
// account" before a QR is shown.
|
||||||
const connector =
|
const connector =
|
||||||
connectors.find((item) => item.id === preferredWallet) ??
|
connectors.find((item) => item.type === "walletConnect") ??
|
||||||
connectors.find((item) => item.id === "walletConnect") ??
|
connectors.find((item) => item.id === "walletConnect");
|
||||||
connectors.find((item) => item.type === "walletConnect");
|
|
||||||
|
|
||||||
if (!connector) {
|
if (!connector) {
|
||||||
pendingRef.current = false;
|
pendingRef.current = false;
|
||||||
@@ -88,11 +109,10 @@ export function useWalletConnectLogin() {
|
|||||||
preferredWallet,
|
preferredWallet,
|
||||||
connectorId: connector.id,
|
connectorId: connector.id,
|
||||||
});
|
});
|
||||||
setQrUri(message.data);
|
if (mode === "qr") setQrUri(message.data);
|
||||||
if (preferredWallet === "tokenPocket" && isMobileDevice()) {
|
const deeplink = walletConnectDeeplink(preferredWallet, message.data);
|
||||||
window.location.href = `tpoutside://wc?uri=${encodeURIComponent(
|
if (mode === "deeplink" && deeplink && isMobileDevice()) {
|
||||||
message.data,
|
window.location.href = deeplink;
|
||||||
)}`;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -120,37 +140,11 @@ export function useWalletConnectLogin() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingRef.current || !isConnected || !address) return;
|
if (!pendingRef.current || !isConnected || !address) return;
|
||||||
pendingRef.current = false;
|
pendingRef.current = false;
|
||||||
setState("signing");
|
completeLogin(localWalletToken(address), address);
|
||||||
let cancelled = false;
|
setQrUri("");
|
||||||
void (async () => {
|
setState("idle");
|
||||||
try {
|
disconnect();
|
||||||
const nonce = await requestWalletNonce(address);
|
}, [address, completeLogin, disconnect, isConnected]);
|
||||||
const signature = await signMessageAsync({ message: nonce.message });
|
|
||||||
const verified = await verifyWalletSignature({
|
|
||||||
address,
|
|
||||||
message: nonce.message,
|
|
||||||
signature,
|
|
||||||
});
|
|
||||||
if (cancelled) return;
|
|
||||||
completeLogin(verified.token, verified.wallet);
|
|
||||||
setState("idle");
|
|
||||||
} catch (err) {
|
|
||||||
if (!cancelled) {
|
|
||||||
setError(
|
|
||||||
err instanceof Error ? err.message : "WalletConnect login failed",
|
|
||||||
);
|
|
||||||
setQrUri("");
|
|
||||||
setState("idle");
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
// We only needed a one-off signature, not a persistent wagmi session.
|
|
||||||
disconnect();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [address, completeLogin, disconnect, isConnected, signMessageAsync]);
|
|
||||||
|
|
||||||
return { available, state, error, qrUri, start, reset };
|
return { available, state, error, qrUri, start, reset };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user