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
2 changes: 2 additions & 0 deletions netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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/*"
Expand Down
28 changes: 28 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
3 changes: 1 addition & 2 deletions src/components/layout/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,7 @@ export function Navbar() {
<Link
to="/"
className="flex items-center gap-2 group"
onClick={() => setMobileOpen(false)}
>

<svg width="28" height="24" viewBox="0 0 28 24">
<rect x="0" y="18" width="6" height="6"
rx="1.5" fill="#1D4ED8"/>
Expand Down
11 changes: 11 additions & 0 deletions src/lib/sanitize.ts
Original file line number Diff line number Diff line change
@@ -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)
}
3 changes: 2 additions & 1 deletion src/pages/LearnerProfile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, 'green' | 'blue' | 'amber' | 'red' | 'muted'> = {
Expand Down Expand Up @@ -272,7 +273,7 @@ export function LearnerProfile() {
<Badge label="Active" variant="green" />
</div>
{vouch.message && (
<p className="text-text-muted text-xs mt-1">{vouch.message}</p>
<p className="text-text-muted text-xs mt-1">{sanitizeText(vouch.message)}</p>
)}
<p className="text-text-muted text-xs mt-1">
{new Date(vouch.createdAt).toLocaleDateString()}
Expand Down
9 changes: 5 additions & 4 deletions src/pages/VendorDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -142,7 +143,7 @@ function LoansTable({
{data.map((loan) => (
<tr key={loan.id} className="border-b border-border/50 hover:bg-surface/50 transition-colors">
<td className="py-3 px-3 text-text-muted font-mono text-xs">{loan.id.slice(0, 8)}...</td>
<td className="py-3 px-3 text-text-primary">{loan.product}</td>
<td className="py-3 px-3 text-text-primary">{sanitizeText(loan.product)}</td>
<td className="py-3 px-3 text-text-secondary font-mono text-xs">
{loan.borrower.slice(0, 6)}...{loan.borrower.slice(-4)}
</td>
Expand Down Expand Up @@ -217,11 +218,11 @@ function ProductsSection({ products, isLoading }: { products?: VendorProduct[];
className="p-4 rounded-xl border border-border bg-elevated/30"
>
<div className="flex items-center justify-between mb-2">
<h4 className="text-text-primary font-medium text-sm">{product.name}</h4>
<h4 className="text-text-primary font-medium text-sm">{sanitizeText(product.name)}</h4>
<Badge label={product.active ? 'Active' : 'Inactive'} variant={product.active ? 'green' : 'muted'} />
</div>
{product.description && (
<p className="text-text-muted text-xs mb-3">{product.description}</p>
<p className="text-text-muted text-xs mb-3">{sanitizeText(product.description)}</p>
)}
<p className="text-text-primary font-display font-bold text-lg">${product.price.toLocaleString()}</p>
</div>
Expand Down Expand Up @@ -341,7 +342,7 @@ function ApiKeySection({
>
<div>
<div className="flex items-center gap-2">
<span className="text-text-primary text-sm font-medium">{key.label}</span>
<span className="text-text-primary text-sm font-medium">{sanitizeText(key.label)}</span>
<Badge label={key.revoked ? 'Revoked' : 'Active'} variant={key.revoked ? 'red' : 'green'} />
</div>
<p className="text-text-muted text-xs font-mono mt-0.5">
Expand Down
24 changes: 19 additions & 5 deletions src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
Expand All @@ -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)
Expand Down
4 changes: 0 additions & 4 deletions src/stores/user.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,9 @@ export const useUserStore = create<UserStore>()(
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: '',
Expand Down
42 changes: 41 additions & 1 deletion vercel.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
]
}
Loading