Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions dongle/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { WalletProvider } from "@/context/wallet.context";
import { Providers } from "./providers";

const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
Expand All @@ -27,9 +28,7 @@ export default function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<WalletProvider>
{children}
</WalletProvider>
<Providers>{children}</Providers>
</body>
</html>
);
Expand Down
12 changes: 12 additions & 0 deletions dongle/app/providers.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<WalletProvider>
<AuthProvider>{children}</AuthProvider>
</WalletProvider>
);
}
117 changes: 117 additions & 0 deletions dongle/context/auth.context.tsx
Original file line number Diff line number Diff line change
@@ -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<AuthContextValue | undefined>(undefined);

/* ------------------------------------------------------------------ */
/* Provider */
/* ------------------------------------------------------------------ */

export function AuthProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<AuthState>({
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<AuthContextValue>(
() => ({ ...state, login, logout }),
[state, login, logout],
);

return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

/* ------------------------------------------------------------------ */
/* Hook */
/* ------------------------------------------------------------------ */

export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth must be used within an <AuthProvider>");
}
return ctx;
}
147 changes: 86 additions & 61 deletions dongle/context/wallet.context.tsx
Original file line number Diff line number Diff line change
@@ -1,94 +1,119 @@
"use client";

import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { walletService } from "@/services/wallet/wallet.service";
import { sessionService } from "@/services/auth/session.service";

interface WalletContextType {
publicKey: string | null;
interface WalletState {
address: string | null;
isConnected: boolean;
isConnecting: boolean;
connectWallet: () => Promise<void>;
disconnectWallet: () => void;
error: string | null;
}

const WalletContext = createContext<WalletContextType | undefined>(undefined);
interface WalletContextValue extends WalletState {
connect: () => Promise<string>;
disconnect: () => void;
}

const WALLET_STORAGE_KEY = "dongle_wallet_state";
const WalletContext = createContext<WalletContextValue | undefined>(undefined);

export function WalletProvider({ children }: { children: ReactNode }) {
const [publicKey, setPublicKey] = useState<string | null>(null);
const [isConnected, setIsConnected] = useState<boolean>(false);
const [isConnecting, setIsConnecting] = useState<boolean>(false);
export function WalletProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<WalletState>({
address: null,
isConnected: false,
isConnecting: true,
error: null,
});

useEffect(() => {
const restoreWalletState = async () => {
async function checkConnection() {
try {
const storedState = localStorage.getItem(WALLET_STORAGE_KEY);
if (storedState) {
const { publicKey: storedKey, isConnected: storedConnected } = JSON.parse(storedState);
if (storedConnected && storedKey) {
const isStillConnected = await walletService.isConnected();
if (isStillConnected) {
const currentKey = await walletService.getPublicKey();
setPublicKey(currentKey);
setIsConnected(true);
} else {
localStorage.removeItem(WALLET_STORAGE_KEY);
}
}
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 (error) {
console.error("Failed to restore wallet state:", error);
localStorage.removeItem(WALLET_STORAGE_KEY);
} catch (err: any) {
setState({
address: null,
isConnected: false,
isConnecting: false,
error: err.message,
});
}
};
restoreWalletState();
}
checkConnection();
}, []);

const connectWallet = useCallback(async () => {
if (isConnected) return;

setIsConnecting(true);
const connect = useCallback(async (): Promise<string> => {
setState((prev) => ({ ...prev, isConnecting: true, error: null }));
try {
const address = await walletService.connectWallet();
setPublicKey(address);
setIsConnected(true);

localStorage.setItem(WALLET_STORAGE_KEY, JSON.stringify({
publicKey: address,
isConnected: true
}));
} catch (error) {
console.error("Wallet connection failed:", error);
} finally {
setIsConnecting(false);
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;
}
}, [isConnected]);
}, []);

const disconnectWallet = useCallback(() => {
setPublicKey(null);
setIsConnected(false);
localStorage.removeItem(WALLET_STORAGE_KEY);
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 (
<WalletContext.Provider
value={{
publicKey,
isConnected,
isConnecting,
connectWallet,
disconnectWallet,
}}
>
<WalletContext.Provider value={value}>
{children}
</WalletContext.Provider>
);
}

export function useWallet(): WalletContextType {
export function useWallet() {
const context = useContext(WalletContext);
if (context === undefined) {
if (!context) {
throw new Error("useWallet must be used within a WalletProvider");
}
return context;
Expand Down
51 changes: 51 additions & 0 deletions dongle/services/auth/session.service.ts
Original file line number Diff line number Diff line change
@@ -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;
},
};