Skip to content

Commit facf476

Browse files
authored
Merge pull request #875 from chainsmt/feat/issue-800
feat(wallet): persist connection across browser sessions
2 parents c65896b + d495ef9 commit facf476

2 files changed

Lines changed: 91 additions & 3 deletions

File tree

frontend/__tests__/wallet-context.test.tsx

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,14 @@ function WalletErrorProbe() {
5858
describe("WalletProvider", () => {
5959
beforeEach(() => {
6060
vi.clearAllMocks();
61+
localStorage.clear();
62+
sessionStorage.clear();
6163
mockGetWalletNetwork.mockResolvedValue("testnet");
6264
mockWatchWalletNetworkChanges.mockReturnValue(() => undefined);
6365
});
6466

65-
it("propagates provider state to consumers", async () => {
67+
it("propagates provider state to consumers when auto-reconnect is enabled", async () => {
68+
localStorage.setItem("stellarwork:wallet-auto-reconnect", "true");
6669
mockGetPublicKey.mockResolvedValue("GINITIALWALLET");
6770

6871
render(
@@ -77,6 +80,48 @@ describe("WalletProvider", () => {
7780
await waitFor(() =>
7881
expect(screen.getByTestId("wallet-network")).toHaveTextContent("testnet"),
7982
);
83+
expect(localStorage.getItem("stellarwork:last-connected-account")).toBe(
84+
"GINITIALWALLET",
85+
);
86+
});
87+
88+
it("skips auto-reconnect after an explicit disconnect", async () => {
89+
localStorage.setItem("stellarwork:wallet-auto-reconnect", "false");
90+
mockGetPublicKey.mockResolvedValue("GSHOULDNOTCONNECT");
91+
92+
render(
93+
<WalletProvider>
94+
<WalletProbe />
95+
</WalletProvider>,
96+
);
97+
98+
await waitFor(() =>
99+
expect(screen.getByTestId("wallet")).toHaveTextContent("none"),
100+
);
101+
expect(mockGetPublicKey).not.toHaveBeenCalled();
102+
});
103+
104+
it("clears stale persistence when Freighter no longer allows access", async () => {
105+
localStorage.setItem("stellarwork:wallet-auto-reconnect", "true");
106+
localStorage.setItem(
107+
"stellarwork:last-connected-account",
108+
"GSTALEWALLET",
109+
);
110+
mockGetPublicKey.mockResolvedValue(null);
111+
112+
render(
113+
<WalletProvider>
114+
<WalletProbe />
115+
</WalletProvider>,
116+
);
117+
118+
await waitFor(() =>
119+
expect(screen.getByTestId("wallet")).toHaveTextContent("none"),
120+
);
121+
expect(localStorage.getItem("stellarwork:last-connected-account")).toBeNull();
122+
expect(localStorage.getItem("stellarwork:wallet-auto-reconnect")).toBe(
123+
"false",
124+
);
80125
});
81126

82127
it("handles connect and disconnect behavior", async () => {
@@ -95,13 +140,20 @@ describe("WalletProvider", () => {
95140
await waitFor(() =>
96141
expect(screen.getByTestId("wallet")).toHaveTextContent("GCONNECTEDWALLET"),
97142
);
143+
expect(localStorage.getItem("stellarwork:wallet-auto-reconnect")).toBe(
144+
"true",
145+
);
98146
await waitFor(() =>
99147
expect(screen.getByTestId("wallet-network")).toHaveTextContent("testnet"),
100148
);
101149

102150
fireEvent.click(screen.getByRole("button", { name: "disconnect" }));
103151
expect(screen.getByTestId("wallet")).toHaveTextContent("none");
104152
expect(screen.getByTestId("wallet-network")).toHaveTextContent("none");
153+
expect(localStorage.getItem("stellarwork:wallet-auto-reconnect")).toBe(
154+
"false",
155+
);
156+
expect(localStorage.getItem("stellarwork:last-connected-account")).toBeNull();
105157
});
106158

107159
it("surfaces connect errors to consumer callers", async () => {

frontend/lib/wallet-context.tsx

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
watchWalletNetworkChanges,
1818
} from "@/lib/stellar";
1919
import type { StellarNetwork } from "@/lib/network-config";
20+
import LegalConsentModal, { hasAcceptedLegal } from "@/components/LegalConsentModal";
2021
import LegalConsentModal, { hasAcceptedLegal, acceptLegal } from "@/components/LegalConsentModal";
2122
import ConfirmDialog from "@/components/ConfirmDialog";
2223
import { CONFIRM_KEYS } from "@/lib/confirm-prefs";
@@ -25,6 +26,7 @@ import { checkConnectionRateLimit, recordConnectionSuccess, recordConnectionFail
2526

2627
// Storage keys
2728
const LAST_ACCOUNT_KEY = "stellarwork:last-connected-account";
29+
const WALLET_AUTO_RECONNECT_KEY = "stellarwork:wallet-auto-reconnect";
2830
const JOB_CACHE_PREFIX = "job-desc:";
2931

3032
interface WalletContextType {
@@ -95,6 +97,30 @@ function persistLastAccount(address: string | null) {
9597
}
9698
}
9799

100+
function getLastConnectedAccount(): string | null {
101+
if (typeof window === "undefined") return null;
102+
return localStorage.getItem(LAST_ACCOUNT_KEY);
103+
}
104+
105+
function getAutoReconnectPreference(): boolean {
106+
if (typeof window === "undefined") return false;
107+
const stored = localStorage.getItem(WALLET_AUTO_RECONNECT_KEY);
108+
if (stored === "true") return true;
109+
if (stored === "false") return false;
110+
// Migrate existing sessions that stored an address before the flag existed.
111+
return getLastConnectedAccount() !== null;
112+
}
113+
114+
function setAutoReconnectPreference(connected: boolean) {
115+
if (typeof window === "undefined") return;
116+
localStorage.setItem(WALLET_AUTO_RECONNECT_KEY, connected ? "true" : "false");
117+
}
118+
119+
function clearPersistedWalletSession() {
120+
persistLastAccount(null);
121+
setAutoReconnectPreference(false);
122+
}
123+
98124
export function WalletProvider({ children }: { children: ReactNode }) {
99125
const [wallet, setWallet] = useState<string | null>(null);
100126
const [walletNetwork, setWalletNetwork] = useState<StellarNetwork | null>(null);
@@ -107,14 +133,22 @@ export function WalletProvider({ children }: { children: ReactNode }) {
107133
setWalletNetwork(nextNetwork);
108134
}, []);
109135

110-
// On mount: restore last session via Freighter if still allowed.
136+
// On mount: restore last session when the user opted in to auto-reconnect.
111137
useEffect(() => {
138+
if (!getAutoReconnectPreference()) {
139+
return;
140+
}
141+
112142
getPublicKey().then(async (key) => {
113143
if (key) {
114144
setWallet(key);
115145
persistLastAccount(key);
116146
await refreshWalletNetwork();
147+
return;
117148
}
149+
150+
// Freighter permission was revoked or the extension is unavailable.
151+
clearPersistedWalletSession();
118152
});
119153
}, [refreshWalletNetwork]);
120154

@@ -208,14 +242,15 @@ export function WalletProvider({ children }: { children: ReactNode }) {
208242
const key = await connectPromiseRef.current;
209243
setWallet(key);
210244
persistLastAccount(key);
245+
setAutoReconnectPreference(true);
211246
await refreshWalletNetwork();
212247
}, [wallet, refreshWalletNetwork]);
213248

214249
const disconnectWallet = useCallback(() => {
215250
clearWalletData();
216251
setWallet(null);
217252
setWalletNetwork(null);
218-
persistLastAccount(null);
253+
clearPersistedWalletSession();
219254
// Clear session display preference
220255
if (typeof window !== "undefined") {
221256
sessionStorage.removeItem("wallet-display-mode");
@@ -242,6 +277,7 @@ export function WalletProvider({ children }: { children: ReactNode }) {
242277
clearWalletData();
243278
setWallet(newKey);
244279
persistLastAccount(newKey);
280+
setAutoReconnectPreference(true);
245281
}
246282
await refreshWalletNetwork();
247283
} catch (err) {

0 commit comments

Comments
 (0)