(null);
const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
+ const [twoFactorPending, setTwoFactorPending] = useState(false);
// ========================================
// SESSION TIMEOUT MANAGEMENT
@@ -44,9 +49,63 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
localStorage.setItem(LAST_ACTIVITY_KEY, Date.now().toString());
}, []);
+ // sessionStorage, not localStorage: 2FA is asked once per browser session and
+ // again after the tab is closed. Persisting it would mean enabling 2FA once and
+ // never being challenged again on that device.
+ const clearTwoFactorVerification = () => {
+ try {
+ sessionStorage.removeItem(TWO_FACTOR_VERIFIED_KEY);
+ } catch {
+ // private mode / storage disabled — the pending flag simply stays in memory
+ }
+ };
+
+ /**
+ * Whether this user still owes a 2FA challenge.
+ *
+ * 2FA is optional, so a user without it enabled is never blocked. A failure to
+ * ask is treated as "not required": a transient error while checking must not
+ * turn into a login wall for people who never enabled 2FA in the first place.
+ */
+ const refreshTwoFactorState = useCallback(async (currentUser: User | null) => {
+ if (!currentUser) {
+ setTwoFactorPending(false);
+ return;
+ }
+
+ try {
+ if (sessionStorage.getItem(TWO_FACTOR_VERIFIED_KEY) === currentUser.id) {
+ setTwoFactorPending(false);
+ return;
+ }
+ } catch {
+ // storage unavailable; fall through and ask again
+ }
+
+ try {
+ const { data, error } = await supabase.functions.invoke('two-factor-auth', {
+ body: { action: 'check' },
+ });
+ setTwoFactorPending(!error && Boolean(data?.enabled));
+ } catch {
+ setTwoFactorPending(false);
+ }
+ }, []);
+
+ const markTwoFactorVerified = useCallback(() => {
+ try {
+ if (user) sessionStorage.setItem(TWO_FACTOR_VERIFIED_KEY, user.id);
+ } catch {
+ // storage unavailable — the in-memory flag below still unblocks this session
+ }
+ setTwoFactorPending(false);
+ }, [user]);
+
const signOut = useCallback(async () => {
setIsAdmin(false);
setUser(null);
+ setTwoFactorPending(false);
+ clearTwoFactorVerification();
localStorage.removeItem(LAST_ACTIVITY_KEY);
localStorage.removeItem(REMEMBER_ME_KEY);
await supabase.auth.signOut();
@@ -187,6 +246,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
supabase.auth.getSession().then(({ data: { session } }) => {
setUser(session?.user ?? null);
checkAdminStatus(); // Check admin status regardless of Supabase session
+ refreshTwoFactorState(session?.user ?? null);
setLoading(false);
}).catch(() => {
// If Supabase is not available, still check wallet-based admin
@@ -197,6 +257,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
// Listen for changes on auth state
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setUser(session?.user ?? null);
+ refreshTwoFactorState(session?.user ?? null);
checkAdminStatus(); // Check admin status on auth change
setLoading(false);
});
@@ -222,7 +283,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
window.removeEventListener('walletChanged', handleWalletChange);
window.removeEventListener('pezkuwi-native-ready', handleNativeReady);
};
- }, [checkAdminStatus, setupMobileWallet]);
+ }, [checkAdminStatus, setupMobileWallet, refreshTwoFactorState]);
const signIn = async (email: string, password: string, rememberMe: boolean = false) => {
try {
@@ -296,6 +357,8 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
user,
loading,
isAdmin,
+ twoFactorPending,
+ markTwoFactorVerified,
signIn,
signUp,
signOut,
diff --git a/web/src/pages/TwoFactorChallenge.tsx b/web/src/pages/TwoFactorChallenge.tsx
new file mode 100644
index 00000000..c2ca06b5
--- /dev/null
+++ b/web/src/pages/TwoFactorChallenge.tsx
@@ -0,0 +1,62 @@
+import { useEffect } from 'react';
+import { useNavigate, useLocation } from 'react-router-dom';
+import { useAuth } from '@/contexts/AuthContext';
+import { TwoFactorVerify } from '@/components/auth/TwoFactorVerify';
+import { Loader2 } from 'lucide-react';
+
+/**
+ * The 2FA challenge shown after a correct password when the account has 2FA on.
+ *
+ * TwoFactorVerify already existed but was referenced from nowhere, so enabling
+ * 2FA protected nothing — the setup screen worked and login never asked. This
+ * page is what connects the two.
+ *
+ * Deliberately outside ProtectedRoute: that route redirects here whenever a
+ * challenge is pending, so guarding this page with it would loop.
+ *
+ * Scope, stated plainly: this is a client-side gate. It stops someone who has
+ * the password from reaching the app in a browser, which is what 2FA is for
+ * here. It is not server-side enforcement — privileged operations verify
+ * authority independently (wallet signature for admin actions, JWT for the
+ * account's own data), and that is what actually protects funds and state.
+ */
+export default function TwoFactorChallenge() {
+ const { user, loading, twoFactorPending, markTwoFactorVerified, signOut } = useAuth();
+ const navigate = useNavigate();
+ const location = useLocation();
+
+ const returnTo = (location.state as { from?: string } | null)?.from ?? '/dashboard';
+
+ useEffect(() => {
+ if (loading) return;
+ // Nothing to challenge: either signed out, or already past it.
+ if (!user) navigate('/login', { replace: true });
+ else if (!twoFactorPending) navigate(returnTo, { replace: true });
+ }, [loading, user, twoFactorPending, navigate, returnTo]);
+
+ if (loading || !user || !twoFactorPending) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {
+ markTwoFactorVerified();
+ navigate(returnTo, { replace: true });
+ }}
+ // Backing out of the challenge has to end the session. Leaving the user
+ // signed in but unchallenged would defeat the point of asking.
+ onCancel={async () => {
+ await signOut();
+ navigate('/login', { replace: true });
+ }}
+ />
+
+ );
+}