diff --git a/next.config.ts b/next.config.ts index 4a81ef66..90890e2c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,7 +2,35 @@ import type { NextConfig } from "next"; import { withSentryConfig } from "@sentry/nextjs"; const nextConfig: NextConfig = { - /* config options here */ + async headers() { + return [ + { + source: "/(.*)", + headers: [ + { + key: "Content-Security-Policy", + value: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:; frame-ancestors 'none';", + }, + { + key: "X-Frame-Options", + value: "DENY", + }, + { + key: "X-Content-Type-Options", + value: "nosniff", + }, + { + key: "Referrer-Policy", + value: "strict-origin-when-cross-origin", + }, + { + key: "Strict-Transport-Security", + value: "max-age=31536000; includeSubDomains", + }, + ], + }, + ]; + }, }; const sentryConfig = { diff --git a/public/sitemap.xml b/public/sitemap.xml new file mode 100644 index 00000000..a1a54b35 --- /dev/null +++ b/public/sitemap.xml @@ -0,0 +1,31 @@ + + + + https://smshub.dev/ + 1.0 + + + https://smshub.dev/login + 0.8 + + + https://smshub.dev/register + 0.8 + + + https://smshub.dev/install + 0.7 + + + https://smshub.dev/phonenumbers + 0.7 + + + https://smshub.dev/privacy + 0.3 + + + https://smshub.dev/terms + 0.3 + + diff --git a/src/app/api/__tests__/health.test.ts b/src/app/api/__tests__/health.test.ts new file mode 100644 index 00000000..f563b2cb --- /dev/null +++ b/src/app/api/__tests__/health.test.ts @@ -0,0 +1,12 @@ +import { describe, it, expect } from "vitest"; + +describe("GET /api/health", () => { + it("returns 200 with status ok", async () => { + const { GET } = await import("@/app/api/health/route"); + const response = await GET(); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.status).toBe("ok"); + expect(json.timestamp).toBeDefined(); + }); +}); diff --git a/src/app/forgot-password/page.tsx b/src/app/forgot-password/page.tsx new file mode 100644 index 00000000..6a1e6d0b --- /dev/null +++ b/src/app/forgot-password/page.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useState } from "react"; +import { createClient } from "@/lib/supabase/client"; +import Link from "next/link"; +import { Logo } from "@/components/logo"; + +export default function ForgotPasswordPage() { + const [email, setEmail] = useState(""); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [loading, setLoading] = useState(false); + + const handleReset = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(null); + + const supabase = createClient(); + const { error } = await supabase.auth.resetPasswordForEmail(email, { + redirectTo: `${window.location.origin}/reset-password`, + }); + + if (error) { + setError(error.message); + } else { + setSuccess(true); + } + setLoading(false); + }; + + return ( + + + + + + + Reset your password + + + {success ? ( + + Check your email for a password reset link. + + ) : ( + + {error && ( + + {error} + + )} + + + + Email + + setEmail(e.target.value)} + required + className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder="you@example.com" + /> + + + + {loading ? "Sending..." : "Send Reset Link"} + + + )} + + + + Back to sign in + + + + + ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d2b70c8c..e09d5cc0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -39,7 +39,6 @@ export const metadata: Metadata = { title: "SMSHub", }, other: { - "mobile-web-app-capable": "yes", "msapplication-TileColor": "#030712", "msapplication-config": "/browserconfig.xml", "msapplication-TileImage": "/icons/apple-touch-icon-144x144.png", @@ -51,12 +50,21 @@ export const metadata: Metadata = { url: "https://smshub.dev", siteName: "SMSHub", type: "website", + images: [ + { + url: "/icons/icon-512x512.png", + width: 512, + height: 512, + alt: "SMSHub", + }, + ], }, twitter: { card: "summary_large_image", title: "SMSHub — Multi-platform SMS Messaging", description: "One inbox for all your SMS. Web, iOS, Android, desktop. Multi-provider support with developer-first API.", + images: ["/icons/icon-512x512.png"], }, }; diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index b4c0ebf9..59b52fbd 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -79,9 +79,15 @@ export default function LoginPage() { value={password} onChange={(e) => setPassword(e.target.value)} required + minLength={8} className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="••••••••" /> + + + Forgot password? + + diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index 9cbd18c9..d575301f 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -25,8 +25,8 @@ export default function RegisterPage() { return; } - if (password.length < 6) { - setError("Password must be at least 6 characters"); + if (password.length < 8) { + setError("Password must be at least 8 characters"); setLoading(false); return; } @@ -92,6 +92,9 @@ export default function RegisterPage() { value={password} onChange={(e) => setPassword(e.target.value)} required + minLength={8} + pattern=".{8,}" + title="Password must be at least 8 characters" className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="••••••••" /> @@ -107,6 +110,7 @@ export default function RegisterPage() { value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} required + minLength={8} className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="••••••••" /> diff --git a/src/components/header.tsx b/src/components/header.tsx index 78d38bf1..cca8d5e5 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -1,13 +1,23 @@ "use client"; +import { useState } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { Logo } from "@/components/logo"; const APP_ROUTES = ["/inbox", "/settings", "/campaigns", "/contacts", "/analytics", "/org"]; +const NAV_LINKS = [ + { href: "/#features", label: "Features" }, + { href: "/#pricing", label: "Pricing" }, + { href: "/#api", label: "API" }, + { href: "/#apps", label: "Apps" }, + { href: "/phonenumbers", label: "Phone Numbers" }, +]; + export function Header() { const pathname = usePathname(); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); // Hide header on app routes (inbox has its own nav) if (APP_ROUTES.some((r) => pathname.startsWith(r))) return null; @@ -18,38 +28,74 @@ export function Header() { - - Features - - - Pricing - - - API - - - Apps - - - Phone Numbers - + {NAV_LINKS.map((link) => ( + + {link.label} + + ))} Sign in Get Started + + {/* Mobile hamburger */} + setMobileMenuOpen(!mobileMenuOpen)} + aria-label="Toggle menu" + > + + {mobileMenuOpen ? ( + + ) : ( + + )} + + + + {/* Mobile dropdown */} + {mobileMenuOpen && ( + + {NAV_LINKS.map((link) => ( + setMobileMenuOpen(false)} + > + {link.label} + + ))} + + setMobileMenuOpen(false)} + > + Sign in + + setMobileMenuOpen(false)} + > + Get Started + + + )} ); } diff --git a/src/lib/__tests__/middleware-public-routes.test.ts b/src/lib/__tests__/middleware-public-routes.test.ts new file mode 100644 index 00000000..941afebd --- /dev/null +++ b/src/lib/__tests__/middleware-public-routes.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; + +// Test that PUBLIC_ROUTES includes critical paths +describe("Public routes config", () => { + it("includes static file paths", async () => { + // Read the middleware source to verify routes + const fs = await import("fs"); + const path = await import("path"); + const content = fs.readFileSync( + path.resolve("src/lib/supabase/middleware.ts"), + "utf-8" + ); + + expect(content).toContain('"/manifest.json"'); + expect(content).toContain('"/robots.txt"'); + expect(content).toContain('"/sitemap.xml"'); + }); + + it("includes API routes", async () => { + const fs = await import("fs"); + const path = await import("path"); + const content = fs.readFileSync( + path.resolve("src/lib/supabase/middleware.ts"), + "utf-8" + ); + + expect(content).toContain('"/api"'); + }); + + it("includes public pages", async () => { + const fs = await import("fs"); + const path = await import("path"); + const content = fs.readFileSync( + path.resolve("src/lib/supabase/middleware.ts"), + "utf-8" + ); + + expect(content).toContain('"/"'); + expect(content).toContain('"/login"'); + expect(content).toContain('"/register"'); + expect(content).toContain('"/privacy"'); + expect(content).toContain('"/terms"'); + expect(content).toContain('"/install"'); + expect(content).toContain('"/phonenumbers"'); + }); +}); diff --git a/src/lib/__tests__/security-headers.test.ts b/src/lib/__tests__/security-headers.test.ts new file mode 100644 index 00000000..3ae1abf1 --- /dev/null +++ b/src/lib/__tests__/security-headers.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from "vitest"; + +describe("Security headers config", () => { + it("next.config.ts includes security headers", async () => { + const fs = await import("fs"); + const path = await import("path"); + const content = fs.readFileSync( + path.resolve("next.config.ts"), + "utf-8" + ); + + expect(content).toContain("X-Frame-Options"); + expect(content).toContain("X-Content-Type-Options"); + expect(content).toContain("Referrer-Policy"); + expect(content).toContain("Strict-Transport-Security"); + }); +}); diff --git a/src/lib/supabase/middleware.ts b/src/lib/supabase/middleware.ts index 25886fe6..3dd15252 100644 --- a/src/lib/supabase/middleware.ts +++ b/src/lib/supabase/middleware.ts @@ -6,14 +6,16 @@ const PUBLIC_ROUTES = [ "/", "/login", "/register", + "/forgot-password", "/phonenumbers", "/offline", "/install", "/privacy", "/terms", - "/api/webhooks", - "/api/v1", - "/api/health", + "/api", + "/manifest.json", + "/robots.txt", + "/sitemap.xml", ]; function isPublicRoute(pathname: string): boolean {
Reset your password
+ + Back to sign in + +