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

@@ -33,7 +33,7 @@ export default function EventsPage() {
const [showCreate, setShowCreate] = useState(false)
useEffect(() => {
fetch("/api/events", { headers: { "x-org-id": "demo" } })
fetch("/api/events")
.then((r) => r.json())
.then((data) => {
if (Array.isArray(data)) setEvents(data)
@@ -49,7 +49,7 @@ export default function EventsPage() {
try {
const res = await fetch("/api/events", {
method: "POST",
headers: { "Content-Type": "application/json", "x-org-id": "demo" },
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...form,
goalAmount: form.goalAmount ? Math.round(parseFloat(form.goalAmount) * 100) : undefined,

View File

@@ -2,7 +2,8 @@
import Link from "next/link"
import { usePathname } from "next/navigation"
import { LayoutDashboard, Calendar, FileBarChart, Upload, Download, Settings, Plus, ExternalLink } from "lucide-react"
import { useSession, signOut } from "next-auth/react"
import { LayoutDashboard, Calendar, FileBarChart, Upload, Download, Settings, Plus, ExternalLink, LogOut } from "lucide-react"
import { cn } from "@/lib/utils"
const navItems = [
@@ -16,6 +17,9 @@ const navItems = [
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const { data: session } = useSession()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const user = session?.user as any
return (
<div className="min-h-screen bg-gray-50/50">
@@ -27,8 +31,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
<span className="text-white font-bold text-sm">P</span>
</div>
<div className="hidden sm:block">
<span className="font-black text-sm text-gray-900">PNPL</span>
<span className="text-[10px] text-muted-foreground ml-1">Dashboard</span>
<span className="font-black text-sm text-gray-900">{user?.orgName || "PNPL"}</span>
</div>
</Link>
<div className="flex-1" />
@@ -38,8 +41,16 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
</button>
</Link>
<Link href="/" className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1">
<ExternalLink className="h-3 w-3" /> <span className="hidden sm:inline">Public Site</span>
<ExternalLink className="h-3 w-3" />
</Link>
{session && (
<button
onClick={() => signOut({ callbackUrl: "/login" })}
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
>
<LogOut className="h-3 w-3" />
</button>
)}
</div>
</header>

View File

@@ -33,7 +33,7 @@ export default function DashboardPage() {
const [whatsappStatus, setWhatsappStatus] = useState<boolean | null>(null)
const fetchData = useCallback(() => {
fetch("/api/dashboard", { headers: { "x-org-id": "demo" } })
fetch("/api/dashboard")
.then(r => r.json())
.then(d => { if (d.summary) setData(d) })
.catch(() => {})

View File

@@ -106,7 +106,7 @@ export default function PledgesPage() {
}, [tab, search, page])
const fetchStats = useCallback(async () => {
const res = await fetch("/api/dashboard", { headers: { "x-org-id": "demo" } })
const res = await fetch("/api/dashboard")
const data = await res.json()
if (data.summary) {
setStats({

View File

@@ -54,7 +54,7 @@ export default function ReconcilePage() {
const res = await fetch("/api/imports/bank-statement", {
method: "POST",
headers: { "x-org-id": "demo" },
headers: { },
body: formData,
})
const data = await res.json()

View File

@@ -31,7 +31,7 @@ export default function SettingsPage() {
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetch("/api/settings", { headers: { "x-org-id": "demo" } })
fetch("/api/settings")
.then((r) => r.json())
.then((data) => { if (data.name) setSettings(data) })
.catch(() => setError("Failed to load settings"))
@@ -44,7 +44,7 @@ export default function SettingsPage() {
try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json", "x-org-id": "demo" },
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
})
if (res.ok) { setSaved(section); setTimeout(() => setSaved(null), 2000) }
@@ -150,7 +150,7 @@ function WhatsAppPanel() {
const res = await fetch("/api/whatsapp/qr")
const data = await res.json()
setStatus(data.status)
if (data.qrImage) setQrImage(data.qrImage)
if (data.screenshot) setQrImage(data.screenshot)
if (data.phone) setPhone(data.phone)
if (data.pushName) setPushName(data.pushName)
} catch {
@@ -231,23 +231,33 @@ function WhatsAppPanel() {
<div className="flex flex-col items-center gap-4">
{qrImage ? (
<div className="relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={qrImage} alt="WhatsApp QR Code" className="w-64 h-64 rounded-xl border-2 border-[#25D366]/20" />
{/* Crop to QR area: the screenshot shows full WhatsApp web page.
QR code is roughly in center. We use overflow hidden + object positioning. */}
<div className="w-72 h-72 rounded-xl border-2 border-[#25D366]/20 overflow-hidden bg-white">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={qrImage}
alt="WhatsApp QR Code"
className="w-[200%] h-auto max-w-none"
style={{ marginLeft: "-30%", marginTop: "-35%" }}
/>
</div>
<div className="absolute -bottom-2 -right-2 w-8 h-8 rounded-full bg-[#25D366] flex items-center justify-center shadow-lg">
<MessageCircle className="h-4 w-4 text-white" />
</div>
</div>
) : (
<div className="w-64 h-64 rounded-xl border-2 border-dashed border-muted flex items-center justify-center">
<div className="w-72 h-72 rounded-xl border-2 border-dashed border-muted flex items-center justify-center">
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
</div>
)}
<div className="text-center space-y-1">
<p className="text-sm font-medium">Scan with WhatsApp</p>
<p className="text-xs text-muted-foreground">QR refreshes automatically every 5 seconds</p>
<p className="text-xs text-muted-foreground">Open WhatsApp Settings Linked Devices Link a Device</p>
<p className="text-xs text-muted-foreground">Auto-refreshes every 5 seconds</p>
</div>
<Button variant="outline" size="sm" onClick={checkStatus} className="gap-1.5">
<RefreshCw className="h-3 w-3" /> Refresh QR
<RefreshCw className="h-3 w-3" /> Refresh
</Button>
</div>
</CardContent>