feat: 3-click SSO registration - unified /auth page, Google/Apple/GitHub OAuth, email fallback, provider model

This commit is contained in:
root
2026-06-15 12:42:03 +02:00
parent 64ae34e9c9
commit d194e295ad
12 changed files with 1150 additions and 294 deletions
BIN
View File
Binary file not shown.
+5
View File
@@ -13,6 +13,9 @@ model User {
email String @unique email String @unique
name String name String
passwordHash String? passwordHash String?
provider String @default("email")
providerId String?
avatar String?
flhBalance Int @default(5000) flhBalance Int @default(5000)
isPro Boolean @default(false) isPro Boolean @default(false)
isPremium Boolean @default(false) isPremium Boolean @default(false)
@@ -29,6 +32,8 @@ model User {
trialEndsAt DateTime? trialEndsAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([provider, providerId])
listings Listing[] listings Listing[]
purchases Purchase[] @relation("BuyerPurchases") purchases Purchase[] @relation("BuyerPurchases")
sales Purchase[] @relation("SellerPurchases") sales Purchase[] @relation("SellerPurchases")
@@ -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<NextResponse> {
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);
}
+63
View File
@@ -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);
}
+1
View File
@@ -29,6 +29,7 @@ export async function POST(req: NextRequest) {
email, email,
name, name,
passwordHash, passwordHash,
provider: "email",
flhBalance: 5000, flhBalance: 5000,
trialEndsAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), trialEndsAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
}, },
+106
View File
@@ -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 (
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
<div className="text-center">
{status === "processing" && (
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
<p className="text-sm text-gray-400">Signing you in...</p>
</div>
)}
{status === "success" && (
<div className="flex flex-col items-center gap-3">
<div className="w-12 h-12 rounded-full bg-green-900/30 border border-green-700/40 flex items-center justify-center">
<span className="text-green-400 text-xl"></span>
</div>
<p className="text-sm text-green-400">Signed in successfully!</p>
</div>
)}
{status === "error" && (
<div className="flex flex-col items-center gap-3">
<div className="w-12 h-12 rounded-full bg-red-900/30 border border-red-700/40 flex items-center justify-center">
<span className="text-red-400 text-xl"></span>
</div>
<p className="text-sm text-red-400">{errorMsg}</p>
<button
onClick={() => router.push("/auth")}
className="mt-4 px-6 py-2 rounded-xl bg-[#D4AF37] text-[#0a0a0f] text-sm font-medium"
>
Back to Login
</button>
</div>
)}
</div>
</div>
);
}
export default function AuthCallbackPage() {
return (
<Suspense
fallback={
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
<p className="text-sm text-gray-400">Signing you in...</p>
</div>
</div>
}
>
<AuthCallbackContent />
</Suspense>
);
}
+379
View File
@@ -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 (
<svg className={className} viewBox="0 0 24 24" fill="none">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
);
}
/** Inline Apple SVG logo */
function AppleLogo({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" />
</svg>
);
}
/** Inline GitHub SVG logo */
function GitHubLogo({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
</svg>
);
}
export default function AuthPage() {
const { login, register, oauthLogin, loading: authLoading } = useAuth();
const router = useRouter();
const [pageState, setPageState] = useState<PageState>("select");
const [mode, setMode] = useState<AuthMode>("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 (
<div className="min-h-dvh bg-[#0a0a0f] flex flex-col items-center justify-center px-5">
<div className="w-full max-w-sm">
{/* Logo / Brand */}
<div className="text-center mb-8">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-[#D4AF37]/20 to-[#D4AF37]/5 border border-[#D4AF37]/30 flex items-center justify-center mx-auto mb-4">
<span className="text-3xl"></span>
</div>
<h1 className="text-2xl font-bold text-white">Welcome to Falah</h1>
<p className="text-sm text-gray-500 mt-1">
Your Islamic lifestyle companion
</p>
</div>
{/* SSO Buttons */}
<div className="space-y-3">
{/* Google */}
<button
onClick={() => handleProviderClick("google")}
disabled={authLoading}
className="w-full flex items-center gap-4 px-5 py-4 rounded-2xl bg-[#111118] border border-gray-800/60 active:bg-[#1a1a24] disabled:opacity-50 transition-all touch-manipulation"
>
<GoogleLogo className="w-6 h-6 shrink-0" />
<span className="text-sm font-medium text-white">
Continue with Google
</span>
</button>
{/* Apple */}
<button
onClick={() => handleProviderClick("apple")}
disabled={authLoading}
className="w-full flex items-center gap-4 px-5 py-4 rounded-2xl bg-[#111118] border border-gray-800/60 active:bg-[#1a1a24] disabled:opacity-50 transition-all touch-manipulation"
>
<AppleLogo className="w-6 h-6 shrink-0 text-white" />
<span className="text-sm font-medium text-white">
Continue with Apple
</span>
</button>
{/* GitHub */}
<button
onClick={() => handleProviderClick("github")}
disabled={authLoading}
className="w-full flex items-center gap-4 px-5 py-4 rounded-2xl bg-[#111118] border border-gray-800/60 active:bg-[#1a1a24] disabled:opacity-50 transition-all touch-manipulation"
>
<GitHubLogo className="w-6 h-6 shrink-0 text-white" />
<span className="text-sm font-medium text-white">
Continue with GitHub
</span>
</button>
{/* Divider */}
<div className="flex items-center gap-3 py-2">
<div className="flex-1 h-px bg-gray-800/60" />
<span className="text-xs text-gray-600">or</span>
<div className="flex-1 h-px bg-gray-800/60" />
</div>
{/* Email */}
<button
onClick={handleEmailClick}
disabled={authLoading}
className="w-full flex items-center gap-4 px-5 py-4 rounded-2xl bg-[#D4AF37]/10 border border-[#D4AF37]/25 active:bg-[#D4AF37]/20 disabled:opacity-50 transition-all touch-manipulation"
>
<div className="w-6 h-6 shrink-0 flex items-center justify-center">
<Mail size={22} className="text-[#D4AF37]" />
</div>
<span className="text-sm font-medium text-[#D4AF37]">
Continue with Email
</span>
</button>
</div>
{/* Demo Credentials */}
<div className="mt-8 rounded-2xl bg-[#111118] border border-gray-800/60 p-4 text-center">
<p className="text-xs text-gray-600 uppercase tracking-wider mb-1.5">
Demo Account
</p>
<p className="text-xs text-gray-500">
Email:{" "}
<span className="text-gray-300 font-mono">demo@falahos.my</span>
</p>
<p className="text-xs text-gray-500">
Password:{" "}
<span className="text-gray-300 font-mono">password123</span>
</p>
</div>
</div>
</div>
);
}
// ── Email Form State ──────────────────────────────────────────────────────
return (
<div className="min-h-dvh bg-[#0a0a0f] flex flex-col items-center justify-center px-5">
<div className="w-full max-w-sm">
{/* Back button */}
<button
onClick={() => setPageState("select")}
className="flex items-center gap-1.5 text-xs text-gray-500 mb-6 active:text-gray-300 transition-colors touch-manipulation"
>
<ArrowLeft size={16} />
All sign-in methods
</button>
{/* Header */}
<div className="text-center mb-7">
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#D4AF37]/20 to-[#D4AF37]/5 border border-[#D4AF37]/30 flex items-center justify-center mx-auto mb-3">
<Mail size={22} className="text-[#D4AF37]" />
</div>
<h1 className="text-xl font-bold text-white">
{mode === "login" ? "Sign In" : "Create Account"}
</h1>
<p className="text-sm text-gray-500 mt-1">
{mode === "login"
? "Welcome back! Sign in to continue"
: "Start your Islamic lifestyle journey"}
</p>
</div>
{/* Error */}
{error && (
<div className="mb-4 p-3 rounded-xl bg-red-900/20 border border-red-800/40 text-red-400 text-sm text-center">
{error}
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Name field (register only) */}
{mode === "register" && (
<div>
<label
htmlFor="auth-name"
className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider"
>
Name
</label>
<input
id="auth-name"
type="text"
value={name}
onChange={(e) => 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"
/>
</div>
)}
{/* Email */}
<div>
<label
htmlFor="auth-email"
className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider"
>
Email
</label>
<input
id="auth-email"
type="email"
value={email}
onChange={(e) => 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"
/>
</div>
{/* Password */}
<div>
<label
htmlFor="auth-password"
className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider"
>
Password
</label>
<input
id="auth-password"
type="password"
value={password}
onChange={(e) => 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"
/>
</div>
{/* Submit */}
<button
type="submit"
disabled={submitting}
className="w-full py-3.5 rounded-xl bg-[#D4AF37] text-[#0a0a0f] font-semibold text-sm active:opacity-80 disabled:opacity-50 transition touch-manipulation"
>
{submitting ? (
<span className="flex items-center justify-center gap-2">
<span className="w-4 h-4 rounded-full border-2 border-[#0a0a0f]/30 border-t-[#0a0a0f] animate-spin" />
{mode === "login" ? "Signing in..." : "Creating account..."}
</span>
) : mode === "login" ? (
"Sign In"
) : (
"Create Account"
)}
</button>
</form>
{/* Toggle mode */}
<div className="text-center mt-6">
<p className="text-xs text-gray-600">
{mode === "login" ? (
<>
Don&apos;t have an account?{" "}
<button
type="button"
onClick={toggleMode}
className="text-[#D4AF37] font-medium hover:underline touch-manipulation"
>
Create one
</button>
</>
) : (
<>
Already have an account?{" "}
<button
type="button"
onClick={toggleMode}
className="text-[#D4AF37] font-medium hover:underline touch-manipulation"
>
Sign in
</button>
</>
)}
</p>
</div>
{/* New Member Bonus (register only) */}
{mode === "register" && (
<div className="mt-6 rounded-xl bg-[#111118] border border-gray-800/60 p-3 text-center">
<p className="text-xs text-gray-600 uppercase tracking-wider mb-1">
New Member Bonus
</p>
<p className="text-xs text-gray-500">
Get <span className="text-[#D4AF37] font-semibold">5,000 FLH</span>{" "}
free on sign up + 7-day premium trial
</p>
</div>
)}
{/* Demo credentials (subtle) */}
<div className="mt-6 rounded-xl bg-[#111118]/60 border border-gray-800/40 p-3 text-center">
<p className="text-xs text-gray-600">
Demo:{" "}
<span className="text-gray-500 font-mono">demo@falahos.my</span>{" "}
<span className="text-gray-600">/</span>{" "}
<span className="text-gray-500 font-mono">password123</span>
</p>
</div>
</div>
</div>
);
}
+6 -139
View File
@@ -1,151 +1,18 @@
"use client"; "use client";
import { useState, FormEvent } from "react"; import { useEffect } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link";
export default function LoginPage() { export default function LoginPage() {
const { login } = useAuth();
const router = useRouter(); 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) => { useEffect(() => {
e.preventDefault(); router.replace("/auth");
setError(""); }, [router]);
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);
}
};
return ( return (
<div className="min-h-dvh bg-[#0a0a0f] flex flex-col items-center justify-center px-6"> <div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
<div className="w-full max-w-sm"> <div className="w-6 h-6 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
{/* Logo / Brand */}
<div className="text-center mb-8">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-[#D4AF37]/20 to-[#D4AF37]/5 border border-[#D4AF37]/30 flex items-center justify-center mx-auto mb-4">
<span className="text-3xl"></span>
</div>
<h1 className="text-2xl font-bold text-white">Welcome Back</h1>
<p className="text-sm text-gray-500 mt-1">
Sign in to continue your journey
</p>
</div>
{/* Error */}
{error && (
<div className="mb-4 p-3 rounded-xl bg-red-900/20 border border-red-800/40 text-red-400 text-sm text-center">
{error}
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider"
>
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => 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"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider"
>
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => 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"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3 rounded-xl bg-[#D4AF37] text-[#0a0a0f] font-semibold text-sm active:opacity-80 disabled:opacity-50 transition"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<span className="w-4 h-4 rounded-full border-2 border-[#0a0a0f]/30 border-t-[#0a0a0f] animate-spin" />
Signing in...
</span>
) : (
"Sign In"
)}
</button>
</form>
{/* Divider */}
<div className="flex items-center gap-3 my-6">
<div className="flex-1 h-px bg-gray-800/60" />
<span className="text-xs text-gray-600">or</span>
<div className="flex-1 h-px bg-gray-800/60" />
</div>
{/* Demo hint */}
<div className="text-center mb-6">
<p className="text-xs text-gray-600">
Don&apos;t have an account?{" "}
<Link
href="/register"
className="text-[#D4AF37] font-medium hover:underline"
>
Create one
</Link>
</p>
</div>
{/* Demo credentials */}
<div className="rounded-xl bg-[#111118] border border-gray-800/60 p-3 text-center">
<p className="text-xs text-gray-600 uppercase tracking-wider mb-1">
Demo Account
</p>
<p className="text-xs text-gray-500">
Email:{" "}
<span className="text-gray-300 font-mono">demo@falahos.my</span>
</p>
<p className="text-xs text-gray-500">
Password:{" "}
<span className="text-gray-300 font-mono">password123</span>
</p>
</div>
</div>
</div> </div>
); );
} }
+6 -154
View File
@@ -1,166 +1,18 @@
"use client"; "use client";
import { useState, FormEvent } from "react"; import { useEffect } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link";
export default function RegisterPage() { export default function RegisterPage() {
const { register } = useAuth();
const router = useRouter(); 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) => { useEffect(() => {
e.preventDefault(); router.replace("/auth");
setError(""); }, [router]);
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);
}
};
return ( return (
<div className="min-h-dvh bg-[#0a0a0f] flex flex-col items-center justify-center px-6"> <div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
<div className="w-full max-w-sm"> <div className="w-6 h-6 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
{/* Logo / Brand */}
<div className="text-center mb-8">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-[#D4AF37]/20 to-[#D4AF37]/5 border border-[#D4AF37]/30 flex items-center justify-center mx-auto mb-4">
<span className="text-3xl"></span>
</div>
<h1 className="text-2xl font-bold text-white">Join Falah</h1>
<p className="text-sm text-gray-500 mt-1">
Start your Islamic lifestyle journey
</p>
</div>
{/* Error */}
{error && (
<div className="mb-4 p-3 rounded-xl bg-red-900/20 border border-red-800/40 text-red-400 text-sm text-center">
{error}
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="name"
className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider"
>
Name
</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => 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"
/>
</div>
<div>
<label
htmlFor="email"
className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider"
>
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => 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"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider"
>
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => 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"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3 rounded-xl bg-[#D4AF37] text-[#0a0a0f] font-semibold text-sm active:opacity-80 disabled:opacity-50 transition"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<span className="w-4 h-4 rounded-full border-2 border-[#0a0a0f]/30 border-t-[#0a0a0f] animate-spin" />
Creating account...
</span>
) : (
"Create Account"
)}
</button>
</form>
{/* Sign in link */}
<div className="text-center mt-6">
<p className="text-xs text-gray-600">
Already have an account?{" "}
<Link
href="/login"
className="text-[#D4AF37] font-medium hover:underline"
>
Sign in
</Link>
</p>
</div>
{/* Bonus info */}
<div className="mt-6 rounded-xl bg-[#111118] border border-gray-800/60 p-3 text-center">
<p className="text-xs text-gray-600 uppercase tracking-wider mb-1">
New Member Bonus
</p>
<p className="text-xs text-gray-500">
Get <span className="text-[#D4AF37] font-semibold">5,000 FLH</span>{" "}
free on sign up + 7-day premium trial
</p>
</div>
</div>
</div> </div>
); );
} }
+5
View File
@@ -23,6 +23,11 @@ const navItems = [
export default function BottomNav() { export default function BottomNav() {
const pathname = usePathname(); const pathname = usePathname();
// Hide bottom nav on auth pages
if (pathname.startsWith("/auth")) {
return null;
}
return ( return (
<nav <nav
className="fixed bottom-0 left-0 right-0 z-50 bg-[#0a0a0f]/95 backdrop-blur-md border-t border-gray-800/60 safe-area-bottom" className="fixed bottom-0 left-0 right-0 z-50 bg-[#0a0a0f]/95 backdrop-blur-md border-t border-gray-800/60 safe-area-bottom"
+32 -1
View File
@@ -40,6 +40,7 @@ interface AuthContextType {
loading: boolean; loading: boolean;
login: (email: string, password: string) => Promise<void>; login: (email: string, password: string) => Promise<void>;
register: (email: string, name: string, password: string) => Promise<void>; register: (email: string, name: string, password: string) => Promise<void>;
oauthLogin: (provider: string) => Promise<void>;
logout: () => void; logout: () => void;
refreshUser: () => Promise<void>; refreshUser: () => Promise<void>;
streak: StreakData | null; streak: StreakData | null;
@@ -65,6 +66,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} else { } else {
setLoading(false); setLoading(false);
} }
// Listen for token changes (e.g., from OAuth popup)
const handleStorage = (e: StorageEvent) => {
if (e.key === "flh_token" && e.newValue) {
setToken(e.newValue);
fetchUser(e.newValue);
}
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, []); }, []);
const fetchUser = async (t: string) => { const fetchUser = async (t: string) => {
@@ -118,6 +129,26 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(data.user); setUser(data.user);
}, []); }, []);
const oauthLogin = useCallback(async (provider: string) => {
// Open a popup window for OAuth
const width = 600;
const height = 700;
const left = window.screenX + (window.innerWidth - width) / 2;
const top = window.screenY + (window.innerHeight - height) / 2;
const popup = window.open(
`/mobile/api/auth/${provider}`,
`oauth-${provider}`,
`width=${width},height=${height},left=${left},top=${top},popup=1`
);
if (!popup) {
// Popup blocked — fall back to direct navigation
window.location.href = `/mobile/api/auth/${provider}`;
}
// The popup will close itself after successful auth via the callback page
}, []);
const logout = useCallback(() => { const logout = useCallback(() => {
localStorage.removeItem("flh_token"); localStorage.removeItem("flh_token");
setToken(null); setToken(null);
@@ -170,7 +201,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, [token, user, refreshStreak, refreshXp]); }, [token, user, refreshStreak, refreshXp]);
return ( return (
<AuthContext.Provider value={{ user, token, loading, login, register, logout, refreshUser, streak, xp, refreshStreak, refreshXp }}> <AuthContext.Provider value={{ user, token, loading, login, register, oauthLogin, logout, refreshUser, streak, xp, refreshStreak, refreshXp }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );
+390
View File
@@ -0,0 +1,390 @@
import { SignJWT, jwtVerify } from "jose";
import { prisma } from "@/lib/prisma";
// ─── Provider Config ────────────────────────────────────────────────────────
export type Provider = "google" | "apple" | "github";
interface ProviderConfig {
authorizeUrl: string;
tokenUrl: string;
scopes: string[];
clientIdEnv: string;
clientSecretEnv: string;
}
export const PROVIDER_CONFIGS: Record<Provider, ProviderConfig> = {
google: {
authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
tokenUrl: "https://oauth2.googleapis.com/token",
scopes: ["openid", "email", "profile"],
clientIdEnv: "GOOGLE_CLIENT_ID",
clientSecretEnv: "GOOGLE_CLIENT_SECRET",
},
apple: {
authorizeUrl: "https://appleid.apple.com/auth/authorize",
tokenUrl: "https://appleid.apple.com/auth/token",
scopes: ["name", "email"],
clientIdEnv: "APPLE_CLIENT_ID",
clientSecretEnv: "APPLE_CLIENT_SECRET",
},
github: {
authorizeUrl: "https://github.com/login/oauth/authorize",
tokenUrl: "https://github.com/login/oauth/access_token",
scopes: ["read:user", "user:email"],
clientIdEnv: "GITHUB_CLIENT_ID",
clientSecretEnv: "GITHUB_CLIENT_SECRET",
},
};
// ─── State Management ────────────────────────────────────────────────────────
const STATE_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "dev-jwt-secret-change-in-production"
);
export interface StatePayload {
provider: Provider;
redirectTo?: string;
}
/** Generate a signed state string for CSRF protection. */
export async function generateState(
provider: Provider,
redirectTo?: string
): Promise<string> {
return new SignJWT({ provider, redirectTo } as unknown as import("jose").JWTPayload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("10m")
.sign(STATE_SECRET);
}
/** Verify a state string and return the payload, or null if invalid/expired. */
export async function verifyState(token: string): Promise<StatePayload | null> {
try {
const { payload } = await jwtVerify(token, STATE_SECRET);
return payload as unknown as StatePayload;
} catch {
return null;
}
}
// ─── Token Exchange & User Info ──────────────────────────────────────────────
interface ProviderUser {
providerId: string;
email: string;
name: string;
avatar: string | null;
}
/**
* Exchange an authorization code for a Google access token,
* then fetch user info.
*/
export async function exchangeGoogleCode(
code: string,
redirectUri: string
): Promise<ProviderUser> {
const clientId = process.env.GOOGLE_CLIENT_ID;
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
if (!clientId || !clientSecret) {
throw new Error("Google OAuth is not configured");
}
// Exchange code for token
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
grant_type: "authorization_code",
}),
});
if (!tokenRes.ok) {
const errBody = await tokenRes.text();
throw new Error(`Google token exchange failed: ${errBody}`);
}
const tokenData = await tokenRes.json();
const accessToken = tokenData.access_token;
// Fetch user info
const userRes = await fetch(
"https://www.googleapis.com/oauth2/v2/userinfo",
{
headers: { Authorization: `Bearer ${accessToken}` },
}
);
if (!userRes.ok) {
throw new Error("Failed to fetch Google user info");
}
const userData = await userRes.json();
return {
providerId: userData.id,
email: userData.email,
name: userData.name || userData.given_name || "User",
avatar: userData.picture || null,
};
}
/**
* Exchange an authorization code for an Apple access token,
* then decode the ID token to get user info.
*/
export async function exchangeAppleCode(
code: string,
redirectUri: string
): Promise<ProviderUser> {
const clientId = process.env.APPLE_CLIENT_ID;
const clientSecret = process.env.APPLE_CLIENT_SECRET;
if (!clientId || !clientSecret) {
throw new Error("Apple OAuth is not configured");
}
// Exchange code for token
const tokenRes = await fetch("https://appleid.apple.com/auth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
grant_type: "authorization_code",
}),
});
if (!tokenRes.ok) {
const errBody = await tokenRes.text();
throw new Error(`Apple token exchange failed: ${errBody}`);
}
const tokenData = await tokenRes.json();
const idToken = tokenData.id_token;
if (!idToken) {
throw new Error("No ID token returned from Apple");
}
// Decode the JWT payload (second segment) — no verification needed here
// since Apple's token endpoint already verified the auth code.
const payloadBase64 = idToken.split(".")[1];
const payloadJson = Buffer.from(payloadBase64, "base64").toString("utf-8");
const payload = JSON.parse(payloadJson);
return {
providerId: payload.sub,
email: payload.email || "",
name:
payload.name ||
`${payload.given_name || ""} ${payload.family_name || ""}`.trim() ||
"User",
avatar: null, // Apple does not provide a profile photo
};
}
/**
* Exchange an authorization code for a GitHub access token,
* then fetch user info (and primary email).
*/
export async function exchangeGitHubCode(
code: string,
redirectUri: string
): Promise<ProviderUser> {
const clientId = process.env.GITHUB_CLIENT_ID;
const clientSecret = process.env.GITHUB_CLIENT_SECRET;
if (!clientId || !clientSecret) {
throw new Error("GitHub OAuth is not configured");
}
// Exchange code for token
const tokenRes = await fetch("https://github.com/login/oauth/access_token", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
}),
});
if (!tokenRes.ok) {
const errBody = await tokenRes.text();
throw new Error(`GitHub token exchange failed: ${errBody}`);
}
const tokenData = await tokenRes.json();
if (tokenData.error) {
throw new Error(`GitHub OAuth error: ${tokenData.error_description || tokenData.error}`);
}
const accessToken = tokenData.access_token;
// Fetch user info
const userRes = await fetch("https://api.github.com/user", {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
"User-Agent": "Falah-App",
},
});
if (!userRes.ok) {
throw new Error("Failed to fetch GitHub user info");
}
const userData = await userRes.json();
// GitHub doesn't always return a public email; fetch emails if needed
let email = userData.email;
if (!email) {
try {
const emailsRes = await fetch("https://api.github.com/user/emails", {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
"User-Agent": "Falah-App",
},
});
if (emailsRes.ok) {
const emails = await emailsRes.json();
const primary = emails.find((e: { primary: boolean }) => e.primary);
if (primary) email = primary.email;
}
} catch {
// fall back to empty email
}
}
return {
providerId: String(userData.id),
email: email || "",
name: userData.name || userData.login || "User",
avatar: userData.avatar_url || null,
};
}
// ─── Dispatch ────────────────────────────────────────────────────────────────
/** Exchange a code for user info based on provider. */
export async function exchangeCode(
provider: Provider,
code: string,
redirectUri: string
): Promise<ProviderUser> {
switch (provider) {
case "google":
return exchangeGoogleCode(code, redirectUri);
case "apple":
return exchangeAppleCode(code, redirectUri);
case "github":
return exchangeGitHubCode(code, redirectUri);
default:
throw new Error(`Unsupported provider: ${provider}`);
}
}
// ─── Find or Create User ─────────────────────────────────────────────────────
export async function findOrCreateOAuthUser(
provider: Provider,
providerUser: ProviderUser
) {
const { providerId, email, name, avatar } = providerUser;
// Try to find by provider + providerId first
let user = await prisma.user.findFirst({
where: { provider, providerId },
});
if (user) {
// Update avatar if provider has a new one
if (avatar && avatar !== user.avatar) {
user = await prisma.user.update({
where: { id: user.id },
data: { avatar },
});
}
return user;
}
// Try to find by email (so OAuth can link to an existing email account)
if (email) {
user = await prisma.user.findUnique({ where: { email } });
if (user) {
// Link the OAuth provider to this existing user
user = await prisma.user.update({
where: { id: user.id },
data: { provider, providerId, avatar: avatar || user.avatar },
});
return user;
}
}
// Create a new user
user = await prisma.user.create({
data: {
email: email || `${providerId}@${provider}.oauth`,
name,
provider,
providerId,
avatar,
flhBalance: 5000,
trialEndsAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
return user;
}
// ─── Build OAuth URL ─────────────────────────────────────────────────────────
/**
* Construct the provider's OAuth authorization URL.
*/
export function buildAuthorizationUrl(
provider: Provider,
state: string,
redirectUri: string
): string {
const config = PROVIDER_CONFIGS[provider];
const clientId = process.env[config.clientIdEnv];
if (!clientId) {
throw new Error(
`${provider} OAuth is not configured. Missing ${config.clientIdEnv} env var.`
);
}
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: "code",
scope: config.scopes.join(" "),
state,
});
// Apple requires response_mode=form_post
if (provider === "apple") {
params.set("response_mode", "form_post");
}
return `${config.authorizeUrl}?${params.toString()}`;
}