diff --git a/prisma/dev.db b/prisma/dev.db index abbb529..83886e5 100644 Binary files a/prisma/dev.db and b/prisma/dev.db differ diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 01f9c33..f923a65 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,6 +13,9 @@ model User { email String @unique name String passwordHash String? + provider String @default("email") + providerId String? + avatar String? flhBalance Int @default(5000) isPro Boolean @default(false) isPremium Boolean @default(false) @@ -29,6 +32,8 @@ model User { trialEndsAt DateTime? createdAt DateTime @default(now()) + @@index([provider, providerId]) + listings Listing[] purchases Purchase[] @relation("BuyerPurchases") sales Purchase[] @relation("SellerPurchases") diff --git a/src/app/api/auth/[provider]/callback/route.ts b/src/app/api/auth/[provider]/callback/route.ts new file mode 100644 index 0000000..e5468d0 --- /dev/null +++ b/src/app/api/auth/[provider]/callback/route.ts @@ -0,0 +1,157 @@ +import { NextRequest, NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { + type Provider, + exchangeCode, + findOrCreateOAuthUser, + verifyState, +} from "@/lib/oauth"; +import { signJWT } from "@/lib/auth"; + +const VALID_PROVIDERS = ["google", "apple", "github"]; + +async function handleCallback( + req: NextRequest, + provider: string +): Promise { + try { + // Grab code and state from query params or POST body (Apple uses form_post) + const url = new URL(req.url); + let code = url.searchParams.get("code"); + let stateParam = url.searchParams.get("state"); + + // Apple may POST form data — try reading POST body + if (!code) { + try { + const contentType = req.headers.get("content-type") || ""; + if (contentType.includes("form")) { + const formData = await req.formData(); + code = formData.get("code") as string | null; + stateParam = formData.get("state") as string | null; + } + } catch { + // not a form POST, that's fine + } + } + + if (!code) { + return NextResponse.json( + { error: "Missing authorization code" }, + { status: 400 } + ); + } + + if (!stateParam) { + return NextResponse.json( + { error: "Missing state parameter" }, + { status: 400 } + ); + } + + // Verify state from cookie (CSRF protection) + const cookieStore = await cookies(); + const storedState = cookieStore.get("oauth_state")?.value; + + if (!storedState) { + return NextResponse.json( + { + error: "Missing state cookie. Please start the OAuth flow again.", + }, + { status: 400 } + ); + } + + const statePayload = await verifyState(stateParam); + if (!statePayload) { + return NextResponse.json( + { + error: + "Invalid or expired state. Please start the OAuth flow again.", + }, + { status: 400 } + ); + } + + // Verify the state and provider match + if (statePayload.provider !== provider) { + return NextResponse.json( + { error: "State/provider mismatch" }, + { status: 400 } + ); + } + + // Clear the state cookie + cookieStore.set("oauth_state", "", { + httpOnly: true, + path: "/", + maxAge: 0, + }); + + // Build the redirect URI (must match the one used in the authorization request) + const baseUrl = `${url.protocol}//${url.host}`; + const redirectUri = `${baseUrl}/mobile/api/auth/${provider}/callback`; + + // Exchange code for user info + const providerUser = await exchangeCode( + provider as Provider, + code, + redirectUri + ); + + // Find or create user in database + const user = await findOrCreateOAuthUser( + provider as Provider, + providerUser + ); + + // Generate JWT + const token = await signJWT({ + userId: user.id, + email: user.email, + isPremium: user.isPremium, + isPro: user.isPro, + }); + + // Redirect to frontend callback page with token + const frontendUrl = `${baseUrl}/mobile/auth/callback?token=${encodeURIComponent(token)}`; + return NextResponse.redirect(frontendUrl); + } catch (error) { + console.error(`${provider} OAuth callback error:`, error); + const message = + error instanceof Error ? error.message : "OAuth callback failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ provider: string }> } +) { + const { provider } = await params; + if (!VALID_PROVIDERS.includes(provider)) { + return NextResponse.json( + { + error: `Unsupported provider. Must be one of: ${VALID_PROVIDERS.join(", ")}`, + }, + { status: 400 } + ); + } + return handleCallback(req, provider); +} + +/** Apple OAuth may use form_post response_mode, which sends a POST. */ +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ provider: string }> } +) { + const { provider } = await params; + if (!VALID_PROVIDERS.includes(provider)) { + return NextResponse.json( + { + error: `Unsupported provider. Must be one of: ${VALID_PROVIDERS.join(", ")}`, + }, + { status: 400 } + ); + } + return handleCallback(req, provider); +} diff --git a/src/app/api/auth/[provider]/route.ts b/src/app/api/auth/[provider]/route.ts new file mode 100644 index 0000000..21af93b --- /dev/null +++ b/src/app/api/auth/[provider]/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { + type Provider, + PROVIDER_CONFIGS, + generateState, + buildAuthorizationUrl, +} from "@/lib/oauth"; + +const VALID_PROVIDERS = ["google", "apple", "github"]; + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ provider: string }> } +) { + const { provider } = await params; + + if (!VALID_PROVIDERS.includes(provider)) { + return NextResponse.json( + { error: `Unsupported provider. Must be one of: ${VALID_PROVIDERS.join(", ")}` }, + { status: 400 } + ); + } + + const config = PROVIDER_CONFIGS[provider as Provider]; + const clientId = process.env[config.clientIdEnv]; + + if (!clientId) { + return NextResponse.json( + { + error: `${provider} OAuth is not configured. Missing ${config.clientIdEnv} environment variable.`, + }, + { status: 503 } + ); + } + + // Build the redirect URI — the callback URL for this provider + const url = new URL(_req.url); + const baseUrl = `${url.protocol}//${url.host}`; + const redirectUri = `${baseUrl}/mobile/api/auth/${provider}/callback`; + + // Generate state for CSRF protection + const state = await generateState(provider as Provider); + + // Store state in a cookie + const cookieStore = await cookies(); + cookieStore.set("oauth_state", state, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 60 * 10, // 10 minutes + }); + + // Build the authorization URL and redirect + const authUrl = buildAuthorizationUrl( + provider as Provider, + state, + redirectUri + ); + + return NextResponse.redirect(authUrl); +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 9474973..cdb04ca 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -29,6 +29,7 @@ export async function POST(req: NextRequest) { email, name, passwordHash, + provider: "email", flhBalance: 5000, trialEndsAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), }, diff --git a/src/app/auth/callback/page.tsx b/src/app/auth/callback/page.tsx new file mode 100644 index 0000000..c15de60 --- /dev/null +++ b/src/app/auth/callback/page.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { Suspense, useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; + +function AuthCallbackContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [status, setStatus] = useState<"processing" | "success" | "error">( + "processing" + ); + const [errorMsg, setErrorMsg] = useState(""); + + useEffect(() => { + const token = searchParams.get("token"); + const error = searchParams.get("error"); + + if (error) { + setStatus("error"); + setErrorMsg(decodeURIComponent(error)); + return; + } + + if (!token) { + setStatus("error"); + setErrorMsg("No authentication token received."); + return; + } + + // Store token in localStorage + localStorage.setItem("flh_token", token); + + // Dispatch a custom event so the AuthContext can pick it up + // (used by popup-based OAuth where the opener listens) + window.dispatchEvent( + new StorageEvent("storage", { + key: "flh_token", + newValue: token, + oldValue: null, + storageArea: localStorage, + }) + ); + + setStatus("success"); + + // If this is a popup window, close it after a brief delay + if (window.opener) { + setTimeout(() => window.close(), 500); + } else { + // Redirect to home + setTimeout(() => router.push("/"), 500); + } + }, [searchParams, router]); + + return ( +
+
+ {status === "processing" && ( +
+
+

Signing you in...

+
+ )} + {status === "success" && ( +
+
+ +
+

Signed in successfully!

+
+ )} + {status === "error" && ( +
+
+ +
+

{errorMsg}

+ +
+ )} +
+
+ ); +} + +export default function AuthCallbackPage() { + return ( + +
+
+

Signing you in...

+
+
+ } + > + +
+ ); +} diff --git a/src/app/auth/page.tsx b/src/app/auth/page.tsx new file mode 100644 index 0000000..691e35e --- /dev/null +++ b/src/app/auth/page.tsx @@ -0,0 +1,379 @@ +"use client"; + +import { useState, FormEvent } from "react"; +import { useAuth } from "@/lib/AuthContext"; +import { useRouter } from "next/navigation"; +import { Mail, ArrowLeft } from "lucide-react"; + +type AuthMode = "login" | "register"; +type PageState = "select" | "email-form"; + +/** Inline Google SVG logo */ +function GoogleLogo({ className }: { className?: string }) { + return ( + + + + + + + ); +} + +/** Inline Apple SVG logo */ +function AppleLogo({ className }: { className?: string }) { + return ( + + + + ); +} + +/** Inline GitHub SVG logo */ +function GitHubLogo({ className }: { className?: string }) { + return ( + + + + ); +} + +export default function AuthPage() { + const { login, register, oauthLogin, loading: authLoading } = useAuth(); + const router = useRouter(); + + const [pageState, setPageState] = useState("select"); + const [mode, setMode] = useState("register"); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const handleProviderClick = (provider: string) => { + setError(""); + oauthLogin(provider); + }; + + const handleEmailClick = () => { + setError(""); + setPageState("email-form"); + }; + + const toggleMode = () => { + setMode((m) => (m === "login" ? "register" : "login")); + setError(""); + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(""); + + if (!email.trim() || !password.trim()) { + setError("Please fill in all fields"); + return; + } + if (mode === "register" && !name.trim()) { + setError("Please enter your name"); + return; + } + if (mode === "register" && password.length < 6) { + setError("Password must be at least 6 characters"); + return; + } + + setSubmitting(true); + try { + if (mode === "login") { + await login(email.trim(), password); + } else { + await register(email.trim(), name.trim(), password); + } + router.push("/"); + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : "Something went wrong. Please try again."; + setError(message); + } finally { + setSubmitting(false); + } + }; + + // ── Select State ────────────────────────────────────────────────────────── + if (pageState === "select") { + return ( +
+
+ {/* Logo / Brand */} +
+
+ ☪️ +
+

Welcome to Falah

+

+ Your Islamic lifestyle companion +

+
+ + {/* SSO Buttons */} +
+ {/* Google */} + + + {/* Apple */} + + + {/* GitHub */} + + + {/* Divider */} +
+
+ or +
+
+ + {/* Email */} + +
+ + {/* Demo Credentials */} +
+

+ Demo Account +

+

+ Email:{" "} + demo@falahos.my +

+

+ Password:{" "} + password123 +

+
+
+
+ ); + } + + // ── Email Form State ────────────────────────────────────────────────────── + return ( +
+
+ {/* Back button */} + + + {/* Header */} +
+
+ +
+

+ {mode === "login" ? "Sign In" : "Create Account"} +

+

+ {mode === "login" + ? "Welcome back! Sign in to continue" + : "Start your Islamic lifestyle journey"} +

+
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Form */} +
+ {/* Name field (register only) */} + {mode === "register" && ( +
+ + setName(e.target.value)} + placeholder="Your name" + autoComplete="name" + className="w-full bg-[#111118] border border-gray-800/60 rounded-xl px-4 py-3.5 text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition text-sm" + /> +
+ )} + + {/* Email */} +
+ + setEmail(e.target.value)} + placeholder="you@example.com" + autoComplete="email" + autoFocus + className="w-full bg-[#111118] border border-gray-800/60 rounded-xl px-4 py-3.5 text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition text-sm" + /> +
+ + {/* Password */} +
+ + setPassword(e.target.value)} + placeholder={mode === "register" ? "At least 6 characters" : "••••••••"} + autoComplete={mode === "login" ? "current-password" : "new-password"} + className="w-full bg-[#111118] border border-gray-800/60 rounded-xl px-4 py-3.5 text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition text-sm" + /> +
+ + {/* Submit */} + +
+ + {/* Toggle mode */} +
+

+ {mode === "login" ? ( + <> + Don't have an account?{" "} + + + ) : ( + <> + Already have an account?{" "} + + + )} +

+
+ + {/* New Member Bonus (register only) */} + {mode === "register" && ( +
+

+ New Member Bonus +

+

+ Get 5,000 FLH{" "} + free on sign up + 7-day premium trial +

+
+ )} + + {/* Demo credentials (subtle) */} +
+

+ Demo:{" "} + demo@falahos.my{" "} + /{" "} + password123 +

+
+
+
+ ); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index e556324..dbb5c58 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,151 +1,18 @@ "use client"; -import { useState, FormEvent } from "react"; -import { useAuth } from "@/lib/AuthContext"; +import { useEffect } from "react"; import { useRouter } from "next/navigation"; -import Link from "next/link"; export default function LoginPage() { - const { login } = useAuth(); const router = useRouter(); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(""); - const [loading, setLoading] = useState(false); - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - setError(""); - - if (!email.trim() || !password.trim()) { - setError("Please fill in all fields"); - return; - } - - setLoading(true); - try { - await login(email.trim(), password); - router.push("/"); - } catch (err: unknown) { - const message = - err instanceof Error ? err.message : "Login failed. Please try again."; - setError(message); - } finally { - setLoading(false); - } - }; + useEffect(() => { + router.replace("/auth"); + }, [router]); return ( -
-
- {/* Logo / Brand */} -
-
- ☪️ -
-

Welcome Back

-

- Sign in to continue your journey -

-
- - {/* Error */} - {error && ( -
- {error} -
- )} - - {/* Form */} -
-
- - setEmail(e.target.value)} - placeholder="you@example.com" - autoComplete="email" - autoFocus - className="w-full bg-[#111118] border border-gray-800/60 rounded-xl px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition" - /> -
- -
- - setPassword(e.target.value)} - placeholder="••••••••" - autoComplete="current-password" - className="w-full bg-[#111118] border border-gray-800/60 rounded-xl px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition" - /> -
- - -
- - {/* Divider */} -
-
- or -
-
- - {/* Demo hint */} -
-

- Don't have an account?{" "} - - Create one - -

-
- - {/* Demo credentials */} -
-

- Demo Account -

-

- Email:{" "} - demo@falahos.my -

-

- Password:{" "} - password123 -

-
-
+
+
); } diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index 23d22e9..d33ea26 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -1,166 +1,18 @@ "use client"; -import { useState, FormEvent } from "react"; -import { useAuth } from "@/lib/AuthContext"; +import { useEffect } from "react"; import { useRouter } from "next/navigation"; -import Link from "next/link"; export default function RegisterPage() { - const { register } = useAuth(); const router = useRouter(); - const [name, setName] = useState(""); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(""); - const [loading, setLoading] = useState(false); - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - setError(""); - - if (!name.trim() || !email.trim() || !password.trim()) { - setError("Please fill in all fields"); - return; - } - - if (password.length < 6) { - setError("Password must be at least 6 characters"); - return; - } - - setLoading(true); - try { - await register(email.trim(), name.trim(), password); - router.push("/"); - } catch (err: unknown) { - const message = - err instanceof Error - ? err.message - : "Registration failed. Please try again."; - setError(message); - } finally { - setLoading(false); - } - }; + useEffect(() => { + router.replace("/auth"); + }, [router]); return ( -
-
- {/* Logo / Brand */} -
-
- ☪️ -
-

Join Falah

-

- Start your Islamic lifestyle journey -

-
- - {/* Error */} - {error && ( -
- {error} -
- )} - - {/* Form */} -
-
- - setName(e.target.value)} - placeholder="Your name" - autoComplete="name" - autoFocus - className="w-full bg-[#111118] border border-gray-800/60 rounded-xl px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition" - /> -
- -
- - setEmail(e.target.value)} - placeholder="you@example.com" - autoComplete="email" - className="w-full bg-[#111118] border border-gray-800/60 rounded-xl px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition" - /> -
- -
- - setPassword(e.target.value)} - placeholder="At least 6 characters" - autoComplete="new-password" - className="w-full bg-[#111118] border border-gray-800/60 rounded-xl px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition" - /> -
- - -
- - {/* Sign in link */} -
-

- Already have an account?{" "} - - Sign in - -

-
- - {/* Bonus info */} -
-

- New Member Bonus -

-

- Get 5,000 FLH{" "} - free on sign up + 7-day premium trial -

-
-
+
+
); } diff --git a/src/components/BottomNav.tsx b/src/components/BottomNav.tsx index e23ba6a..8524519 100644 --- a/src/components/BottomNav.tsx +++ b/src/components/BottomNav.tsx @@ -23,6 +23,11 @@ const navItems = [ export default function BottomNav() { const pathname = usePathname(); + // Hide bottom nav on auth pages + if (pathname.startsWith("/auth")) { + return null; + } + return (