diff --git a/README.md b/README.md index 60de66f..9239179 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ React TypeScript web application for the StepFi BNPL protocol on Stellar. **Live URL:** https://stepfi-web.vercel.app +[![CI](https://github.com/StepFi-app/StepFi-Web/actions/workflows/ci.yml/badge.svg)](https://github.com/StepFi-app/StepFi-Web/actions/workflows/ci.yml) + ## Stack Vite, React 18, TypeScript, Tailwind CSS, Zustand, TanStack Query, Freighter API diff --git a/netlify.toml b/netlify.toml index d15200d..79bd4fb 100644 --- a/netlify.toml +++ b/netlify.toml @@ -10,10 +10,12 @@ [[headers]] for = "/*" [headers.values] + Content-Security-Policy = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://stepfi-api.onrender.com; font-src 'self'; frame-ancestors 'none'; form-action 'self'; base-uri 'self'" X-Frame-Options = "DENY" X-Content-Type-Options = "nosniff" Referrer-Policy = "strict-origin-when-cross-origin" Permissions-Policy = "camera=(), microphone=(), geolocation=()" + Strict-Transport-Security = "max-age=31536000; includeSubDomains; preload" [[headers]] for = "/assets/*" diff --git a/package-lock.json b/package-lock.json index 56ca604..e15be9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@tanstack/react-query": "^5.101.0", "axios": "^1.18.0", "clsx": "^2.1.1", + "dompurify": "^3.4.11", "framer-motion": "^12.40.0", "lucide-react": "^1.18.0", "react": "^19.2.6", @@ -22,6 +23,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/dompurify": "^3.0.5", "@types/node": "^24.13.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -1036,6 +1038,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1087,6 +1099,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.61.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz", @@ -1952,6 +1971,15 @@ "dev": true, "license": "MIT" }, + "node_modules/dompurify": { + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/package.json b/package.json index c0f3b07..d3d94f8 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@tanstack/react-query": "^5.101.0", "axios": "^1.18.0", "clsx": "^2.1.1", + "dompurify": "^3.4.11", "framer-motion": "^12.40.0", "lucide-react": "^1.18.0", "react": "^19.2.6", @@ -24,6 +25,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/dompurify": "^3.0.5", "@types/node": "^24.13.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/src/components/layout/Navbar.tsx b/src/components/layout/Navbar.tsx index 1625bd5..0fd68cc 100644 --- a/src/components/layout/Navbar.tsx +++ b/src/components/layout/Navbar.tsx @@ -40,7 +40,11 @@ export function Navbar() {
- + setMobileOpen(false)} + > diff --git a/src/hooks/useTransaction.ts b/src/hooks/useTransaction.ts new file mode 100644 index 0000000..e0d2b44 --- /dev/null +++ b/src/hooks/useTransaction.ts @@ -0,0 +1,52 @@ +import { useState } from 'react' +import { signTransaction } from '@stellar/freighter-api' +import { STELLAR_NETWORK } from '../constants/config' + +interface UseTransactionReturn { + isLoading: boolean + error: string | null + execute: ( + getXdr: () => Promise<{ xdr: string }>, + submit: (signedXdr: string) => Promise + ) => Promise +} + +export function useTransaction(): UseTransactionReturn { + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + const execute = async ( + getXdr: () => Promise<{ xdr: string }>, + submit: (signedXdr: string) => Promise + ): Promise => { + setIsLoading(true) + setError(null) + try { + const { xdr } = await getXdr() + + const networkPassphrase = (STELLAR_NETWORK as string) === 'PUBLIC' + ? 'Public Global Stellar Network ; October 2015' + : 'Test SDF Network ; September 2015' + + const signedResponse = await signTransaction(xdr, { + networkPassphrase, + }) + + if (!signedResponse || signedResponse.error) { + throw new Error(typeof signedResponse.error === 'string' ? signedResponse.error : 'User rejected the transaction') + } + + const result = await submit(signedResponse.signedTxXdr) + return result + } catch (err: unknown) { + console.error('Transaction failed:', err) + const message = err instanceof Error ? err.message : 'Transaction failed' + setError(message) + throw err + } finally { + setIsLoading(false) + } + } + + return { isLoading, error, execute } +} diff --git a/src/lib/sanitize.ts b/src/lib/sanitize.ts new file mode 100644 index 0000000..853fd6d --- /dev/null +++ b/src/lib/sanitize.ts @@ -0,0 +1,11 @@ +import DOMPurify from 'dompurify' + +DOMPurify.setConfig({ ALLOWED_TAGS: [], ALLOWED_ATTR: [] }) + +export function sanitizeText(input: string): string { + return DOMPurify.sanitize(input, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] }) +} + +export function sanitizeHtml(input: string): string { + return DOMPurify.sanitize(input) +} diff --git a/src/pages/LearnerProfile.tsx b/src/pages/LearnerProfile.tsx index e8e6049..b20b956 100644 --- a/src/pages/LearnerProfile.tsx +++ b/src/pages/LearnerProfile.tsx @@ -10,6 +10,7 @@ import { Card } from '../components/ui/Card' import { Button } from '../components/ui/Button' import { Badge } from '../components/ui/Badge' import { Spinner } from '../components/ui/Spinner' +import { sanitizeText } from '../lib/sanitize' import type { LearnerProfile, ReputationHistoryPoint, Vouch, Loan } from '../types' const TIER_COLORS: Record = { @@ -272,7 +273,7 @@ export function LearnerProfile() {
{vouch.message && ( -

{vouch.message}

+

{sanitizeText(vouch.message)}

)}

{new Date(vouch.createdAt).toLocaleDateString()} diff --git a/src/pages/Sponsors.tsx b/src/pages/Sponsors.tsx index 3219e69..f3beb56 100644 --- a/src/pages/Sponsors.tsx +++ b/src/pages/Sponsors.tsx @@ -1,11 +1,230 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { sponsorsService } from '../services/sponsors.service' +import { useTransaction } from '../hooks/useTransaction' +import { useWallet } from '../hooks/useWallet' +import { Button } from '../components/ui/Button' +import { Card } from '../components/ui/Card' +import { Badge } from '../components/ui/Badge' +import { Spinner } from '../components/ui/Spinner' +import { + TrendingUp, + Wallet, + ArrowUpRight, + CheckCircle2, + AlertCircle, + ExternalLink +} from 'lucide-react' + export function Sponsors() { + const { isConnected } = useWallet() + const [shares, setShares] = useState('') + const [successData, setSuccessData] = useState<{ + hash: string + amount: number + profit: number + } | null>(null) + + const { data: poolInfo, isLoading: poolLoading } = useQuery({ + queryKey: ['poolInfo'], + queryFn: sponsorsService.getPoolInfo, + enabled: isConnected + }) + + const { execute, isLoading: txLoading, error: txError } = useTransaction() + + const handleWithdraw = async (e: React.FormEvent) => { + e.preventDefault() + if (!shares || isNaN(Number(shares))) return + + try { + const result = await execute( + () => sponsorsService.withdraw(Number(shares)), + (signedXdr) => sponsorsService.submitTransaction(signedXdr) + ) + setSuccessData(result) + setShares('') + } catch { + // Error handled by useTransaction and shown in UI + } + } + + if (!isConnected) { + return ( +

+

+ Connect your wallet to sponsor +

+

+ You need a Stellar wallet to manage your liquidity pool shares. +

+
+ ) + } + + const previewUsdc = Number(shares) * (poolInfo?.sharePrice || 0) + return (
-

Sponsor Dashboard

-

- Pool stats and deposit options coming soon. -

+
+

+ Sponsor Dashboard +

+

+ Manage your deposits and earn yield from learner loans. +

+
+ +
+
+
+ +
+
+ +
+ +
+

Total Deposits

+

+ {poolLoading ? : `${poolInfo?.totalDeposits.toLocaleString()} USDC`} +

+
+ + +
+
+ +
+
+

Available Liquidity

+

+ {poolLoading ? : `${poolInfo?.availableLiquidity.toLocaleString()} USDC`} +

+
+
+ + +

+ Liquidity Pool Details +

+
+
+

Total Shares

+

+ {poolLoading ? '...' : poolInfo?.totalShares.toLocaleString()} +

+
+
+

Share Price

+

+ {poolLoading ? '...' : `${poolInfo?.sharePrice.toFixed(4)} USDC`} +

+
+
+

Locked Capital

+

+ {poolLoading ? '...' : `${poolInfo?.lockedLiquidity.toLocaleString()} USDC`} +

+
+
+

Status

+

Active

+
+
+
+
+ +
+ +

+ Withdraw Funds +

+
+
+ +
+ setShares(e.target.value)} + placeholder="0.00" + className="w-full bg-bg border border-border rounded-xl px-4 py-3 + text-text-primary focus:outline-none focus:border-brand transition-colors" + /> +
+ SHARES +
+
+
+ + {Number(shares) > 0 && ( +
+
+ Preview Value: + {previewUsdc.toFixed(2)} USDC +
+

+ Estimated amount based on current share price. Final amount may vary slightly. +

+
+ )} + + {txError && ( +
+ +

{txError}

+
+ )} + + +
+
+ + {successData && ( + +
+
+ +
+

Withdrawal Successful

+
+ +
+
+ Amount Received: + {successData.amount} USDC +
+
+ Realized Profit: + +{successData.profit} USDC +
+
+ + + View on Stellar.expert + + +
+ )} +
+
) } diff --git a/src/pages/VendorDashboard.tsx b/src/pages/VendorDashboard.tsx index fdc3120..582140a 100644 --- a/src/pages/VendorDashboard.tsx +++ b/src/pages/VendorDashboard.tsx @@ -11,6 +11,7 @@ import { Card } from '../components/ui/Card' import { Button } from '../components/ui/Button' import { Badge } from '../components/ui/Badge' import { Spinner } from '../components/ui/Spinner' +import { sanitizeText } from '../lib/sanitize' import type { VendorDashboardOverview, VendorLoan, VendorPayment, VendorProduct, ApiKey, PaginatedResponse @@ -142,7 +143,7 @@ function LoansTable({ {data.map((loan) => ( {loan.id.slice(0, 8)}... - {loan.product} + {sanitizeText(loan.product)} {loan.borrower.slice(0, 6)}...{loan.borrower.slice(-4)} @@ -217,11 +218,11 @@ function ProductsSection({ products, isLoading }: { products?: VendorProduct[]; className="p-4 rounded-xl border border-border bg-elevated/30" >
-

{product.name}

+

{sanitizeText(product.name)}

{product.description && ( -

{product.description}

+

{sanitizeText(product.description)}

)}

${product.price.toLocaleString()}

@@ -341,7 +342,7 @@ function ApiKeySection({ >
- {key.label} + {sanitizeText(key.label)}

diff --git a/src/services/api.ts b/src/services/api.ts index 8993a30..1d0937a 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -8,10 +8,25 @@ export const api = axios.create({ }, }) +const TOKEN_STORAGE_KEY = 'stepfi-user' + +function getStoredTokens(): { accessToken?: string; refreshToken?: string } { + try { + const raw = localStorage.getItem(TOKEN_STORAGE_KEY) + if (!raw) return {} + const parsed = JSON.parse(raw) + const state = parsed?.state + if (!state) return {} + return { accessToken: state.accessToken, refreshToken: state.refreshToken } + } catch { + return {} + } +} + api.interceptors.request.use((config) => { - const token = localStorage.getItem('accessToken') - if (token) { - config.headers.Authorization = `Bearer ${token}` + const { accessToken } = getStoredTokens() + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}` } return config }) @@ -20,8 +35,7 @@ api.interceptors.response.use( (response) => response, async (error) => { if (error.response?.status === 401) { - localStorage.removeItem('accessToken') - localStorage.removeItem('refreshToken') + localStorage.removeItem(TOKEN_STORAGE_KEY) window.location.href = '/' } return Promise.reject(error) diff --git a/src/services/sponsors.service.ts b/src/services/sponsors.service.ts new file mode 100644 index 0000000..08db6be --- /dev/null +++ b/src/services/sponsors.service.ts @@ -0,0 +1,23 @@ +import { api } from './api' +import type { PoolInfo } from '../types' + +export const sponsorsService = { + getPoolInfo: async (): Promise => { + const res = await api.get('/liquidity/pool-info') + return res.data + }, + + withdraw: async (shares: number): Promise<{ xdr: string }> => { + const res = await api.post('/liquidity/withdraw', { shares }) + return res.data + }, + + submitTransaction: async (signedXdr: string): Promise<{ + hash: string + amount: number + profit: number + }> => { + const res = await api.post('/liquidity/submit', { xdr: signedXdr }) + return res.data + }, +} diff --git a/src/stores/user.store.ts b/src/stores/user.store.ts index 85fabc9..14f1f9c 100644 --- a/src/stores/user.store.ts +++ b/src/stores/user.store.ts @@ -16,13 +16,9 @@ export const useUserStore = create()( refreshToken: '', isAuthenticated: false, setTokens: (accessToken, refreshToken) => { - localStorage.setItem('accessToken', accessToken) - localStorage.setItem('refreshToken', refreshToken) set({ accessToken, refreshToken, isAuthenticated: true }) }, clearTokens: () => { - localStorage.removeItem('accessToken') - localStorage.removeItem('refreshToken') set({ accessToken: '', refreshToken: '', diff --git a/vercel.json b/vercel.json index 170b3f6..af092bd 100644 --- a/vercel.json +++ b/vercel.json @@ -2,5 +2,45 @@ "framework": "vite", "buildCommand": "npm run build", "outputDirectory": "dist", - "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] + "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }], + "headers": [ + { + "source": "/(.*)", + "headers": [ + { + "key": "Content-Security-Policy", + "value": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://stepfi-api.onrender.com; font-src 'self'; frame-ancestors 'none'; form-action 'self'; base-uri 'self'" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Referrer-Policy", + "value": "strict-origin-when-cross-origin" + }, + { + "key": "Permissions-Policy", + "value": "camera=(), microphone=(), geolocation=()" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000; includeSubDomains; preload" + } + ] + }, + { + "source": "/assets/(.*)", + "headers": [ + { + "key": "Cache-Control", + "value": "public, max-age=31536000, immutable" + } + ] + } + ] }