From 841fcdbf5438e87a3e4db6eda90b14ba0435caec Mon Sep 17 00:00:00 2001 From: Kurdistan Tech Ministry Date: Wed, 8 Apr 2026 02:23:27 +0300 Subject: [PATCH] feat: integrate Bereketli via iframe with Supabase token bridge B2B button now opens Bereketli (bereketli.pezkiwi.app) embedded in an iframe. PWAP exchanges the user's Supabase JWT for a Bereketli JWT via the existing /v1/auth/exchange endpoint, then passes tokens to the iframe via postMessage. User never sees a login screen. - New /bereketli route (ProtectedRoute) - Token caching in localStorage (10 min TTL) - Camera + geolocation permissions on iframe - Desktop and mobile layouts supported - Re-auth on token expiry via postMessage Co-Authored-By: Claude Opus 4.6 (1M context) --- web/src/App.tsx | 6 + web/src/components/MobileHomeLayout.tsx | 2 +- web/src/pages/Bereketli.tsx | 164 ++++++++++++++++++++++++ 3 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 web/src/pages/Bereketli.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 355c2523..7f2f6e99 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -34,6 +34,7 @@ const WalletDashboard = lazy(() => import('./pages/WalletDashboard')); const ReservesDashboardPage = lazy(() => import('./pages/ReservesDashboardPage')); const BeCitizen = lazy(() => import('./pages/BeCitizen')); const Identity = lazy(() => import('./pages/Identity')); +const Bereketli = lazy(() => import('./pages/Bereketli')); const Citizens = lazy(() => import('./pages/Citizens')); const CitizensIssues = lazy(() => import('./pages/citizens/CitizensIssues')); const GovernmentEntrance = lazy(() => import('./pages/citizens/GovernmentEntrance')); @@ -147,6 +148,11 @@ function App() { } /> } /> } /> + + + + } /> } /> } /> } /> diff --git a/web/src/components/MobileHomeLayout.tsx b/web/src/components/MobileHomeLayout.tsx index e70da828..129b3e8d 100644 --- a/web/src/components/MobileHomeLayout.tsx +++ b/web/src/components/MobileHomeLayout.tsx @@ -56,7 +56,7 @@ const APP_SECTIONS: AppSection[] = [ { title: 'mobile.app.bank', icon: '🏦', route: '/wallet', comingSoon: true }, { title: 'mobile.app.exchange', icon: '💱', route: '/dex', requiresAuth: true }, { title: 'mobile.app.p2p', icon: '🤝', route: '/p2p', requiresAuth: true }, - { title: 'mobile.app.b2b', icon: '🤖', route: '/wallet', comingSoon: true }, + { title: 'mobile.app.b2b', icon: '🤖', route: '/bereketli', requiresAuth: true }, { title: 'mobile.app.bacZekat', icon: '💰', route: '/wallet', comingSoon: true }, { title: 'mobile.app.launchpad', icon: '🚀', route: '/launchpad' }, ], diff --git a/web/src/pages/Bereketli.tsx b/web/src/pages/Bereketli.tsx new file mode 100644 index 00000000..9c010530 --- /dev/null +++ b/web/src/pages/Bereketli.tsx @@ -0,0 +1,164 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { useIsMobile } from '@/hooks/use-mobile'; +import MobileShell from '@/components/MobileShell'; +import { supabase } from '@/lib/supabase'; +import { Loader2, RefreshCw } from 'lucide-react'; + +const BEREKETLI_URL = 'https://bereketli.pezkiwi.app'; +const BEREKETLI_API = `${BEREKETLI_URL}/v1`; +const CACHE_KEY = 'pwap_bereketli_tokens'; + +interface CachedTokens { + access_token: string; + refresh_token: string; + timestamp: number; +} + +export default function Bereketli() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const isMobile = useIsMobile(); + const iframeRef = useRef(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [tokens, setTokens] = useState(null); + + const exchangeToken = useCallback(async () => { + setLoading(true); + setError(null); + + try { + // Check cache first (valid for 10 minutes) + const cached = localStorage.getItem(CACHE_KEY); + if (cached) { + const parsed: CachedTokens = JSON.parse(cached); + if (Date.now() - parsed.timestamp < 10 * 60 * 1000) { + setTokens(parsed); + setLoading(false); + return; + } + } + + // Get Supabase session + const { data: { session } } = await supabase.auth.getSession(); + if (!session?.access_token) { + setError(t('bereketli.noSession', 'Please login first')); + setLoading(false); + return; + } + + // Exchange Supabase token for Bereketli token + const res = await fetch(`${BEREKETLI_API}/auth/exchange`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ supabase_token: session.access_token }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.message || `Exchange failed (${res.status})`); + } + + const data = await res.json(); + const newTokens: CachedTokens = { + access_token: data.access_token, + refresh_token: data.refresh_token, + timestamp: Date.now(), + }; + + localStorage.setItem(CACHE_KEY, JSON.stringify(newTokens)); + setTokens(newTokens); + } catch (err) { + setError(err instanceof Error ? err.message : 'Token exchange failed'); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + exchangeToken(); + }, [exchangeToken]); + + // Send tokens to iframe after it loads + const handleIframeLoad = useCallback(() => { + if (!tokens || !iframeRef.current?.contentWindow) return; + iframeRef.current.contentWindow.postMessage({ + type: 'bereketli:auth-inject', + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + }, BEREKETLI_URL); + }, [tokens]); + + // Listen for messages from iframe + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + if (event.origin !== BEREKETLI_URL) return; + if (event.data?.type === 'bereketli:auth-required') { + // Token expired, re-exchange + localStorage.removeItem(CACHE_KEY); + exchangeToken(); + } + }; + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, [exchangeToken]); + + const content = ( +
+ {loading ? ( +
+
+ +

{t('bereketli.connecting', 'Connecting to Bereketli...')}

+
+
+ ) : error ? ( +
+
+

{error}

+ +
+
+ ) : ( +