From 408e17c9b9d58d56034d1e488083f34c08f836ed Mon Sep 17 00:00:00 2001 From: rahimatonize Date: Wed, 25 Feb 2026 15:24:17 +0100 Subject: [PATCH] feat: Implement initial authentication and wallet management using React contexts and a session service. --- dongle/app/layout.tsx | 3 +- dongle/app/providers.tsx | 12 +++ dongle/context/auth.context.tsx | 117 +++++++++++++++++++++++ dongle/context/wallet.context.tsx | 120 ++++++++++++++++++++++++ dongle/services/auth/session.service.ts | 51 ++++++++++ 5 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 dongle/app/providers.tsx create mode 100644 dongle/context/auth.context.tsx create mode 100644 dongle/context/wallet.context.tsx create mode 100644 dongle/services/auth/session.service.ts diff --git a/dongle/app/layout.tsx b/dongle/app/layout.tsx index f7fa87e..6ce4e25 100644 --- a/dongle/app/layout.tsx +++ b/dongle/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import { Providers } from "./providers"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -27,7 +28,7 @@ export default function RootLayout({ - {children} + {children} ); diff --git a/dongle/app/providers.tsx b/dongle/app/providers.tsx new file mode 100644 index 0000000..459bcae --- /dev/null +++ b/dongle/app/providers.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { AuthProvider } from "@/context/auth.context"; +import { WalletProvider } from "@/context/wallet.context"; + +export function Providers({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/dongle/context/auth.context.tsx b/dongle/context/auth.context.tsx new file mode 100644 index 0000000..35bfad0 --- /dev/null +++ b/dongle/context/auth.context.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import { sessionService } from "@/services/auth/session.service"; +import { walletService } from "@/services/wallet/wallet.service"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +interface AuthState { + publicKey: string | null; + isAuthenticated: boolean; + isLoading: boolean; +} + +interface AuthContextValue extends AuthState { + login: (publicKey: string) => void; + logout: () => void; +} + +/* ------------------------------------------------------------------ */ +/* Context */ +/* ------------------------------------------------------------------ */ + +const AuthContext = createContext(undefined); + +/* ------------------------------------------------------------------ */ +/* Provider */ +/* ------------------------------------------------------------------ */ + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [state, setState] = useState({ + publicKey: null, + isAuthenticated: false, + isLoading: true, + }); + + /* ---- restore session on mount ---- */ + useEffect(() => { + async function restore() { + const session = sessionService.getSession(); + + if (session) { + // Verify the wallet is still connected + const connected = await walletService.isConnected(); + + if (connected) { + setState({ + publicKey: session.publicKey, + isAuthenticated: true, + isLoading: false, + }); + return; + } + + // Wallet disconnected externally — clear stale session + sessionService.clearSession(); + } + + setState({ + publicKey: null, + isAuthenticated: false, + isLoading: false, + }); + } + + restore(); + }, []); + + /* ---- actions ---- */ + const login = useCallback((publicKey: string) => { + sessionService.createSession(publicKey); + setState({ + publicKey, + isAuthenticated: true, + isLoading: false, + }); + }, []); + + const logout = useCallback(() => { + walletService.disconnectWallet(); + sessionService.clearSession(); + setState({ + publicKey: null, + isAuthenticated: false, + isLoading: false, + }); + }, []); + + /* ---- memoised value ---- */ + const value = useMemo( + () => ({ ...state, login, logout }), + [state, login, logout], + ); + + return {children}; +} + +/* ------------------------------------------------------------------ */ +/* Hook */ +/* ------------------------------------------------------------------ */ + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error("useAuth must be used within an "); + } + return ctx; +} diff --git a/dongle/context/wallet.context.tsx b/dongle/context/wallet.context.tsx new file mode 100644 index 0000000..2f32134 --- /dev/null +++ b/dongle/context/wallet.context.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import { walletService } from "@/services/wallet/wallet.service"; +import { sessionService } from "@/services/auth/session.service"; + +interface WalletState { + address: string | null; + isConnected: boolean; + isConnecting: boolean; + error: string | null; +} + +interface WalletContextValue extends WalletState { + connect: () => Promise; + disconnect: () => void; +} + +const WalletContext = createContext(undefined); + +export function WalletProvider({ children }: { children: React.ReactNode }) { + const [state, setState] = useState({ + address: null, + isConnected: false, + isConnecting: true, + error: null, + }); + + useEffect(() => { + async function checkConnection() { + try { + const connected = await walletService.isConnected(); + if (connected) { + const address = await walletService.getPublicKey(); + setState({ + address, + isConnected: true, + isConnecting: false, + error: null, + }); + } else { + setState({ + address: null, + isConnected: false, + isConnecting: false, + error: null, + }); + } + } catch (err: any) { + setState({ + address: null, + isConnected: false, + isConnecting: false, + error: err.message, + }); + } + } + checkConnection(); + }, []); + + const connect = useCallback(async (): Promise => { + setState((prev) => ({ ...prev, isConnecting: true, error: null })); + try { + const address = await walletService.connectWallet(); + sessionService.createSession(address); + setState({ + address, + isConnected: true, + isConnecting: false, + error: null, + }); + return address; + } catch (err: any) { + setState({ + address: null, + isConnected: false, + isConnecting: false, + error: err.message, + }); + throw err; + } + }, []); + + const disconnect = useCallback(() => { + walletService.disconnectWallet(); + sessionService.clearSession(); + setState({ + address: null, + isConnected: false, + isConnecting: false, + error: null, + }); + }, []); + + const value = useMemo( + () => ({ ...state, connect, disconnect }), + [state, connect, disconnect] + ); + + return ( + + {children} + + ); +} + +export function useWallet() { + const context = useContext(WalletContext); + if (!context) { + throw new Error("useWallet must be used within a WalletProvider"); + } + return context; +} diff --git a/dongle/services/auth/session.service.ts b/dongle/services/auth/session.service.ts new file mode 100644 index 0000000..cd12b42 --- /dev/null +++ b/dongle/services/auth/session.service.ts @@ -0,0 +1,51 @@ +const SESSION_KEY = "dongle_session"; + +export interface Session { + publicKey: string; + connectedAt: string; +} + +export const sessionService = { + /** + * Persist a new session for the given wallet public key. + */ + createSession(publicKey: string): Session { + const session: Session = { + publicKey, + connectedAt: new Date().toISOString(), + }; + localStorage.setItem(SESSION_KEY, JSON.stringify(session)); + return session; + }, + + /** + * Retrieve the current session, or null if none exists / is corrupt. + */ + getSession(): Session | null { + try { + const raw = localStorage.getItem(SESSION_KEY); + if (!raw) return null; + + const parsed = JSON.parse(raw); + if (!parsed?.publicKey) return null; + + return parsed as Session; + } catch { + return null; + } + }, + + /** + * Remove the persisted session. + */ + clearSession(): void { + localStorage.removeItem(SESSION_KEY); + }, + + /** + * Quick check — true when a valid session record is present. + */ + isAuthenticated(): boolean { + return this.getSession() !== null; + }, +};