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
42 changes: 25 additions & 17 deletions frontend/src/components/NavBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import { Link, useLocation } from 'react-router-dom';
import DarkModeToggle from './DarkModeToggle';
import Tooltip from './Tooltip';
import WalletConnectionProgress from './WalletConnectionProgress';
import { useAuth } from '../hooks/useFreighter';

const NAV_LINKS = [
Expand All @@ -19,7 +20,7 @@ function truncate(addr) {
}

function WalletIndicator() {
const { publicKey, connect, disconnect } = useAuth();
const { publicKey, connect, disconnect, loading, connectionStep, error } = useAuth();
const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false);
const ref = useRef(null);
Expand All @@ -34,22 +35,29 @@ function WalletIndicator() {

if (!publicKey) {
return (
<button
onClick={connect}
style={{
padding: '0.6rem 1rem',
background: 'var(--btn-primary)',
color: '#fff',
border: 'none',
borderRadius: 6,
fontSize: '0.85rem',
cursor: 'pointer',
minHeight: '44px',
minWidth: '44px',
}}
>
Connect Wallet
</button>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', alignItems: 'flex-end' }}>
<button
onClick={connect}
disabled={loading}
aria-busy={loading}
style={{
padding: '0.6rem 1rem',
background: 'var(--btn-primary)',
color: '#fff',
border: 'none',
borderRadius: 6,
fontSize: '0.85rem',
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.7 : 1,
minHeight: '44px',
minWidth: '44px',
}}
>
{loading ? 'Connecting…' : 'Connect Wallet'}
</button>
{loading && <WalletConnectionProgress step={connectionStep} />}
{!loading && error && <WalletConnectionProgress error={error} />}
</div>
);
}

Expand Down
66 changes: 66 additions & 0 deletions frontend/src/components/WalletConnectionProgress.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { CONNECTION_STEPS } from '../hooks/useFreighter';

const STEP_ORDER = [
CONNECTION_STEPS.CHECKING,
CONNECTION_STEPS.REQUESTING_KEY,
CONNECTION_STEPS.REQUESTING_CHALLENGE,
CONNECTION_STEPS.SIGNING,
CONNECTION_STEPS.VERIFYING,
];

const spinnerStyle = {
width: 16,
height: 16,
border: '2px solid #334155',
borderTopColor: '#38bdf8',
borderRadius: '50%',
display: 'inline-block',
animation: 'spin 0.7s linear infinite',
flexShrink: 0,
};

/**
* Shown while wallet connection is in progress.
* Renders a spinner + current step message.
*/
export default function WalletConnectionProgress({ step, error }) {
if (!step && !error) return null;

return (
<div
role="status"
aria-live="polite"
aria-label={error ? `Connection error: ${error}` : `Connecting wallet: ${step}`}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.5rem 0.75rem',
background: error ? '#1c0a0a' : '#0f172a',
border: `1px solid ${error ? '#f87171' : '#334155'}`,
borderRadius: 6,
fontSize: '0.8rem',
color: error ? '#f87171' : '#94a3b8',
minWidth: 0,
maxWidth: 260,
}}
>
{error ? (
<span aria-hidden="true" style={{ flexShrink: 0 }}>⚠️</span>
) : (
<>
<span style={spinnerStyle} aria-hidden="true" />
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</>
)}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{error || step}
</span>
{!error && (
<span style={{ color: '#475569', flexShrink: 0, fontSize: '0.7rem' }}>
{STEP_ORDER.indexOf(step) + 1}/{STEP_ORDER.length}
</span>
)}
</div>
);
}
24 changes: 21 additions & 3 deletions frontend/src/hooks/useFreighter.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,37 @@ const AuthContext = createContext(null);
// Only persist publicKey and role — token is kept in memory only
const STORAGE_KEY = 'vaccichain_wallet';

export const CONNECTION_STEPS = {
IDLE: null,
CHECKING: 'Checking Freighter wallet…',
REQUESTING_KEY: 'Requesting public key…',
REQUESTING_CHALLENGE: 'Requesting SEP-10 challenge…',
SIGNING: 'Waiting for wallet signature…',
VERIFYING: 'Verifying signature…',
};

export function AuthProvider({ children }) {
const toast = useToast();
const [publicKey, setPublicKey] = useState(null);
const [role, setRole] = useState(null);
const [loading, setLoading] = useState(false);
const [connectionStep, setConnectionStep] = useState(CONNECTION_STEPS.IDLE);
const [error, setError] = useState(null);
const [freighterInstalled, setFreighterInstalled] = useState(() => typeof window !== 'undefined' && !!window.freighter);
// Token lives only in memory — never written to localStorage
const tokenRef = useRef(null);

const runSep10 = useCallback(async (pk) => {
const runSep10 = useCallback(async (pk, onStep) => {
onStep?.(CONNECTION_STEPS.REQUESTING_CHALLENGE);
const challengeRes = await fetch('/v1/auth/sep10', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ public_key: pk }),
});
const { transaction, nonce } = await challengeRes.json();
onStep?.(CONNECTION_STEPS.SIGNING);
const signedXDR = await signTransaction(transaction, { network: 'TESTNET' });
onStep?.(CONNECTION_STEPS.VERIFYING);
const verifyRes = await fetch('/v1/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
Expand All @@ -39,16 +52,19 @@ export function AuthProvider({ children }) {
}, []);

const connect = useCallback(async () => {
if (loading) return; // prevent duplicate attempts
setLoading(true);
setError(null);
setConnectionStep(CONNECTION_STEPS.CHECKING);
try {
const connected = await isConnected();
if (!connected) {
setFreighterInstalled(false);
throw new Error('Freighter wallet not found. Please install it.');
}
setConnectionStep(CONNECTION_STEPS.REQUESTING_KEY);
const pk = await getPublicKey();
const data = await runSep10(pk);
const data = await runSep10(pk, setConnectionStep);
setPublicKey(pk);
tokenRef.current = data.token;
setRole(data.role);
Expand All @@ -63,8 +79,9 @@ export function AuthProvider({ children }) {
throw e;
} finally {
setLoading(false);
setConnectionStep(CONNECTION_STEPS.IDLE);
}
}, [runSep10, toast]);
}, [loading, runSep10, toast]);

const disconnect = useCallback(() => {
setPublicKey(null);
Expand Down Expand Up @@ -130,6 +147,7 @@ export function AuthProvider({ children }) {
apiFetch,
isConnected: isConnectedState,
loading,
connectionStep,
error,
}}>
{children}
Expand Down