production auth: signup, login, protected dashboard, landing page, WAHA QR fix

AUTH:
- NextAuth with credentials provider (bcrypt password hashing)
- /api/auth/signup: creates org + user in transaction
- /login, /signup pages with clean minimal UI
- Middleware protects all /dashboard/* routes → redirects to /login
- Session-based org resolution (no more hardcoded 'demo' headers)
- SessionProvider wraps entire app
- Dashboard header shows org name + sign out button

LANDING PAGE:
- Full marketing page at / with hero, problem, how-it-works, features, CTA
- 'Get Started Free' → /signup → auto-login → /dashboard/setup
- Clean responsive design, no auth required for public pages

WAHA QR FIX:
- WAHA CORE doesn't expose QR value via API or webhook
- Now uses /api/screenshot (full browser capture) with CSS crop to QR area
- Settings panel shows cropped screenshot with overflow:hidden
- Auto-polls every 5s, refresh button

MULTI-TENANT:
- getOrgId() tries session first, then header, then first-org fallback
- All dashboard APIs use session-based org
- Signup creates isolated org per charity
This commit is contained in:
2026-03-03 05:37:04 +08:00
parent 6894f091fd
commit 4f23f28873
22 changed files with 708 additions and 221 deletions

View File

@@ -1,50 +1,9 @@
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
import { withAuth } from "next-auth/middleware"
// Simple in-memory rate limiter (use Redis in production)
const rateLimit = new Map<string, { count: number; resetAt: number }>()
function checkRateLimit(ip: string, limit: number = 60, windowMs: number = 60000): boolean {
const now = Date.now()
const entry = rateLimit.get(ip)
if (!entry || entry.resetAt < now) {
rateLimit.set(ip, { count: 1, resetAt: now + windowMs })
return true
}
if (entry.count >= limit) return false
entry.count++
return true
}
export function middleware(request: NextRequest) {
const response = NextResponse.next()
// Rate limit API routes
if (request.nextUrl.pathname.startsWith("/api/")) {
const ip = request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip") || "unknown"
if (!checkRateLimit(ip)) {
return NextResponse.json(
{ error: "Too many requests" },
{ status: 429 }
)
}
}
// Add security headers
response.headers.set("X-Frame-Options", "SAMEORIGIN")
response.headers.set("X-Content-Type-Options", "nosniff")
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin")
// Allow iframe embedding for pledge pages
if (request.nextUrl.pathname.startsWith("/p/")) {
response.headers.delete("X-Frame-Options")
}
return response
}
export default withAuth({
pages: { signIn: "/login" },
})
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
matcher: ["/dashboard/:path*"],
}