Skip to content

Commit 8da26d3

Browse files
authored
Merge pull request #182 from idrisososanwo/auth-session
Improved auth/session handling
2 parents 3cfbe85 + 1489551 commit 8da26d3

5 files changed

Lines changed: 378 additions & 0 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { render, screen, fireEvent } from "@testing-library/react";
2+
import { describe, it, expect, vi } from "vitest";
3+
import SessionExpiredModal from "./SessionExpiredModal";
4+
5+
describe("SessionExpiredModal", () => {
6+
it("renders the session expired heading", () => {
7+
render(
8+
<SessionExpiredModal
9+
intendedPath="/portfolio"
10+
onReconnect={vi.fn()}
11+
onDismiss={vi.fn()}
12+
/>,
13+
);
14+
15+
expect(screen.getByText("Session Expired")).toBeInTheDocument();
16+
});
17+
18+
it("displays the intended path when it is not root", () => {
19+
render(
20+
<SessionExpiredModal
21+
intendedPath="/analytics"
22+
onReconnect={vi.fn()}
23+
onDismiss={vi.fn()}
24+
/>,
25+
);
26+
27+
expect(screen.getByText("/analytics")).toBeInTheDocument();
28+
});
29+
30+
it("does not display path badge when path is root", () => {
31+
render(
32+
<SessionExpiredModal
33+
intendedPath="/"
34+
onReconnect={vi.fn()}
35+
onDismiss={vi.fn()}
36+
/>,
37+
);
38+
39+
// The path badge should not be rendered for "/"
40+
expect(screen.queryByText("/")).not.toBeInTheDocument();
41+
});
42+
43+
it("calls onReconnect when Reconnect Wallet button is clicked", () => {
44+
const onReconnect = vi.fn();
45+
46+
render(
47+
<SessionExpiredModal
48+
intendedPath="/portfolio"
49+
onReconnect={onReconnect}
50+
onDismiss={vi.fn()}
51+
/>,
52+
);
53+
54+
fireEvent.click(screen.getByRole("button", { name: /reconnect wallet/i }));
55+
expect(onReconnect).toHaveBeenCalledTimes(1);
56+
});
57+
58+
it("calls onDismiss when Go to Home button is clicked", () => {
59+
const onDismiss = vi.fn();
60+
61+
render(
62+
<SessionExpiredModal
63+
intendedPath="/portfolio"
64+
onReconnect={vi.fn()}
65+
onDismiss={onDismiss}
66+
/>,
67+
);
68+
69+
fireEvent.click(screen.getByRole("button", { name: /go to home/i }));
70+
expect(onDismiss).toHaveBeenCalledTimes(1);
71+
});
72+
73+
it("has correct ARIA role and labels for accessibility", () => {
74+
render(
75+
<SessionExpiredModal
76+
intendedPath="/"
77+
onReconnect={vi.fn()}
78+
onDismiss={vi.fn()}
79+
/>,
80+
);
81+
82+
const dialog = screen.getByRole("dialog");
83+
expect(dialog).toHaveAttribute("aria-modal", "true");
84+
expect(dialog).toHaveAttribute("aria-labelledby", "session-expired-title");
85+
expect(dialog).toHaveAttribute("aria-describedby", "session-expired-desc");
86+
});
87+
});
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import React from "react";
2+
import { createPortal } from "react-dom";
3+
import { Lock, Wallet, Home } from "lucide-react";
4+
5+
interface SessionExpiredModalProps {
6+
intendedPath: string;
7+
onReconnect: () => void;
8+
onDismiss: () => void;
9+
}
10+
11+
const SessionExpiredModal: React.FC<SessionExpiredModalProps> = ({
12+
intendedPath,
13+
onReconnect,
14+
onDismiss,
15+
}) => {
16+
return createPortal(
17+
<div
18+
className="session-expired-overlay"
19+
role="dialog"
20+
aria-modal="true"
21+
aria-labelledby="session-expired-title"
22+
aria-describedby="session-expired-desc"
23+
>
24+
<div className="session-expired-modal glass-panel">
25+
<div
26+
style={{
27+
background: "var(--bg-error)",
28+
color: "var(--text-error)",
29+
padding: "16px",
30+
borderRadius: "50%",
31+
display: "flex",
32+
alignItems: "center",
33+
justifyContent: "center",
34+
marginBottom: "8px",
35+
}}
36+
>
37+
<Lock size={48} />
38+
</div>
39+
40+
<div style={{ textAlign: "center" }}>
41+
<h1
42+
id="session-expired-title"
43+
className="text-gradient"
44+
style={{ fontSize: "1.8rem", marginBottom: "12px" }}
45+
>
46+
Session Expired
47+
</h1>
48+
<p
49+
id="session-expired-desc"
50+
style={{
51+
color: "var(--text-secondary)",
52+
fontSize: "1rem",
53+
lineHeight: "1.6",
54+
marginBottom: "8px",
55+
}}
56+
>
57+
Your wallet session is no longer authorised. Please reconnect
58+
Freighter to continue where you left off.
59+
</p>
60+
{intendedPath && intendedPath !== "/" && (
61+
<p
62+
style={{
63+
color: "var(--text-tertiary)",
64+
fontSize: "0.875rem",
65+
fontFamily: "monospace",
66+
background: "var(--bg-muted)",
67+
display: "inline-block",
68+
padding: "4px 10px",
69+
borderRadius: "var(--radius-sm)",
70+
marginBottom: "4px",
71+
}}
72+
>
73+
{intendedPath}
74+
</p>
75+
)}
76+
</div>
77+
78+
<div
79+
style={{
80+
display: "flex",
81+
flexDirection: "column",
82+
gap: "12px",
83+
width: "100%",
84+
marginTop: "8px",
85+
}}
86+
>
87+
<button
88+
id="session-expired-reconnect"
89+
className="btn btn-primary animate-glow"
90+
onClick={onReconnect}
91+
style={{ width: "100%", padding: "14px" }}
92+
>
93+
<Wallet size={18} />
94+
Reconnect Wallet
95+
</button>
96+
97+
<button
98+
className="btn btn-outline"
99+
onClick={onDismiss}
100+
style={{ width: "100%", padding: "14px" }}
101+
>
102+
<Home size={18} />
103+
Go to Home
104+
</button>
105+
</div>
106+
</div>
107+
</div>,
108+
document.body,
109+
);
110+
};
111+
112+
export default SessionExpiredModal;
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { render, screen, act } from "@testing-library/react";
2+
import { describe, it, expect } from "vitest";
3+
import { AuthProvider, useAuth } from "./AuthContext";
4+
5+
// Helper component to expose context values
6+
function TestConsumer({
7+
onAction,
8+
}: {
9+
onAction?: (actions: { expire: () => void; clear: () => void }) => void;
10+
}) {
11+
const { sessionState, intendedPath, setSessionExpired, clearSessionExpired } =
12+
useAuth();
13+
14+
// Surface actions for the test to call imperatively
15+
if (onAction) {
16+
onAction({ expire: () => setSessionExpired("/portfolio"), clear: clearSessionExpired });
17+
}
18+
19+
return (
20+
<div>
21+
<span data-testid="state">{sessionState}</span>
22+
<span data-testid="path">{intendedPath}</span>
23+
</div>
24+
);
25+
}
26+
27+
describe("AuthContext", () => {
28+
it("starts with idle session state", () => {
29+
render(
30+
<AuthProvider>
31+
<TestConsumer />
32+
</AuthProvider>,
33+
);
34+
expect(screen.getByTestId("state").textContent).toBe("idle");
35+
});
36+
37+
it("transitions to expired and captures intended path", () => {
38+
let actions: { expire: () => void; clear: () => void } | undefined;
39+
40+
render(
41+
<AuthProvider>
42+
<TestConsumer
43+
onAction={(a) => {
44+
actions = a;
45+
}}
46+
/>
47+
</AuthProvider>,
48+
);
49+
50+
act(() => {
51+
actions!.expire();
52+
});
53+
54+
expect(screen.getByTestId("state").textContent).toBe("expired");
55+
expect(screen.getByTestId("path").textContent).toBe("/portfolio");
56+
});
57+
58+
it("resets to idle after clearSessionExpired", () => {
59+
let actions: { expire: () => void; clear: () => void } | undefined;
60+
61+
render(
62+
<AuthProvider>
63+
<TestConsumer
64+
onAction={(a) => {
65+
actions = a;
66+
}}
67+
/>
68+
</AuthProvider>,
69+
);
70+
71+
act(() => {
72+
actions!.expire();
73+
});
74+
expect(screen.getByTestId("state").textContent).toBe("expired");
75+
76+
act(() => {
77+
actions!.clear();
78+
});
79+
expect(screen.getByTestId("state").textContent).toBe("idle");
80+
});
81+
82+
it("does not flip to expired twice (idempotent)", () => {
83+
let actions: { expire: () => void; clear: () => void } | undefined;
84+
85+
render(
86+
<AuthProvider>
87+
<TestConsumer
88+
onAction={(a) => {
89+
actions = a;
90+
}}
91+
/>
92+
</AuthProvider>,
93+
);
94+
95+
act(() => {
96+
actions!.expire();
97+
// second call should be a no-op
98+
actions!.expire();
99+
});
100+
101+
expect(screen.getByTestId("state").textContent).toBe("expired");
102+
// Path captured on first call stays unchanged
103+
expect(screen.getByTestId("path").textContent).toBe("/portfolio");
104+
});
105+
106+
it("throws when useAuth is used outside AuthProvider", () => {
107+
// Suppress expected React error boundary noise
108+
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
109+
expect(() => render(<TestConsumer />)).toThrow(
110+
"useAuth must be used within an AuthProvider",
111+
);
112+
spy.mockRestore();
113+
});
114+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import React, { createContext, useCallback, useContext, useState } from "react";
2+
3+
export type SessionState = "idle" | "expired";
4+
5+
interface AuthContextType {
6+
sessionState: SessionState;
7+
intendedPath: string;
8+
setSessionExpired: (path: string) => void;
9+
clearSessionExpired: () => void;
10+
}
11+
12+
const AuthContext = createContext<AuthContextType | undefined>(undefined);
13+
14+
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
15+
children,
16+
}) => {
17+
const [sessionState, setSessionState] = useState<SessionState>("idle");
18+
const [intendedPath, setIntendedPath] = useState("/");
19+
20+
const setSessionExpired = useCallback((path: string) => {
21+
// Guard against flipping to expired more than once per session
22+
setSessionState((current) => {
23+
if (current === "expired") return current;
24+
setIntendedPath(path);
25+
return "expired";
26+
});
27+
}, []);
28+
29+
const clearSessionExpired = useCallback(() => {
30+
setSessionState("idle");
31+
}, []);
32+
33+
return (
34+
<AuthContext.Provider
35+
value={{ sessionState, intendedPath, setSessionExpired, clearSessionExpired }}
36+
>
37+
{children}
38+
</AuthContext.Provider>
39+
);
40+
};
41+
42+
// eslint-disable-next-line react-refresh/only-export-components
43+
export function useAuth() {
44+
const context = useContext(AuthContext);
45+
if (!context) {
46+
throw new Error("useAuth must be used within an AuthProvider");
47+
}
48+
return context;
49+
}

0 commit comments

Comments
 (0)