diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..242112d --- /dev/null +++ b/deno.lock @@ -0,0 +1,40 @@ +{ + "version": "5", + "remote": { + "https://edge.netlify.com/": "fd941d61d88673d5f28aab283fb86fcc50f08a3bc80ee5470498fcfa88c65cfb", + "https://edge.netlify.com/bootstrap/config.ts": "6a2ce0e544e15e8f8883a5c18da5948e37fd0f2619f68cb31f3af53c51817025", + "https://edge.netlify.com/bootstrap/context.ts": "e97240232121e2f369f6546ce961490f34d961ea1ea54be3ff09633e3f08373f", + "https://edge.netlify.com/bootstrap/cookie.ts": "8b0baae708989ca183c6f3b4ab3d029e6abcbc2e43f93edeb0ff447b3bbc3a05", + "https://edge.netlify.com/bootstrap/edge_function.ts": "b8253e86aa83c67341f5cfedeba5049d77fbf84dcab7eceff7566b7728ae9b39", + "https://edge.netlify.com/bootstrap/globals/types.ts": "eaa6148ded3121d8dee62dd91c86e7fe76601df0f3ca8d7962243a30f4c8935f" + }, + "workspace": { + "packageJson": { + "dependencies": [ + "npm:@netlify/plugin-nextjs@^5.15.12", + "npm:@prisma/client@^5.22.0", + "npm:@tailwindcss/postcss@^4.3.1", + "npm:@types/leaflet@^1.9.21", + "npm:@types/node@20", + "npm:@types/react-dom@19", + "npm:@types/react@19.2.17", + "npm:bcryptjs@^3.0.3", + "npm:eslint-config-next@16.2.7", + "npm:eslint@9", + "npm:jose@^6.2.3", + "npm:leaflet@^1.9.4", + "npm:lucide-react@^1.18.0", + "npm:next@16.2.7", + "npm:pdfkit@~0.19.1", + "npm:prisma@^5.22.0", + "npm:qrcode@^1.5.4", + "npm:react-dom@19.2.4", + "npm:react-leaflet@5", + "npm:react@19.2.4", + "npm:stripe@^22.2.1", + "npm:tailwindcss@^4.3.1", + "npm:typescript@5.9.3" + ] + } + } +} diff --git a/next.config.ts b/next.config.ts index 8b0f420..bd9235b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,4 +1,5 @@ import type { NextConfig } from "next"; +import path from "path"; const nextConfig: NextConfig = { basePath: "/mobile", @@ -8,6 +9,9 @@ const nextConfig: NextConfig = { experimental: { optimizePackageImports: ["lucide-react"], }, + turbopack: { + root: path.resolve(import.meta.dirname || __dirname), + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 1a64c3e..04cb4ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,12 +27,12 @@ "@netlify/plugin-nextjs": "^5.15.12", "@tailwindcss/postcss": "^4.3.1", "@types/node": "^20", - "@types/react": "^19", + "@types/react": "19.2.17", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.2.7", "tailwindcss": "^4.3.1", - "typescript": "^5" + "typescript": "5.9.3" } }, "node_modules/@alloc/quick-lru": { diff --git a/package.json b/package.json index 5cb0f49..a55cace 100644 --- a/package.json +++ b/package.json @@ -28,11 +28,11 @@ "@netlify/plugin-nextjs": "^5.15.12", "@tailwindcss/postcss": "^4.3.1", "@types/node": "^20", - "@types/react": "^19", + "@types/react": "19.2.17", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.2.7", "tailwindcss": "^4.3.1", - "typescript": "^5" + "typescript": "5.9.3" } } diff --git a/src/app/api/auth/casdoor-callback/route.ts b/src/app/api/auth/casdoor-callback/route.ts new file mode 100644 index 0000000..2ff6ff3 --- /dev/null +++ b/src/app/api/auth/casdoor-callback/route.ts @@ -0,0 +1,79 @@ +import { NextRequest, NextResponse } from "next/server"; + +const CLIENT_ID = process.env.NEXT_PUBLIC_CASDOOR_CLIENT_ID || "272f91105da5cf984773"; +const CLIENT_SECRET = process.env.CASDOOR_CLIENT_SECRET; +const CASDOOR_TOKEN_URL = "https://auth.falahos.my/api/login/oauth/access_token"; +const CASDOOR_USERINFO_URL = "https://auth.falahos.my/api/userinfo"; + +export async function GET(req: NextRequest) { + try { + const code = req.nextUrl.searchParams.get("code"); + const state = req.nextUrl.searchParams.get("state"); + + if (!code) { + return NextResponse.json({ error: "Missing authorization code" }, { status: 400 }); + } + + if (!CLIENT_SECRET) { + return NextResponse.json({ error: "Casdoor client secret not configured" }, { status: 500 }); + } + + // Exchange authorization code for tokens + const tokenRes = await fetch(CASDOOR_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code, + // redirect_uri must match exactly what was used in authorize request + redirect_uri: "https://mobile2.falah-os.com/auth/callback", + }), + }); + + if (!tokenRes.ok) { + const errBody = await tokenRes.text(); + console.error("[casdoor-oidc] Token exchange failed:", tokenRes.status, errBody); + return NextResponse.json({ error: "Token exchange failed" }, { status: 502 }); + } + + const tokenData = await tokenRes.json(); + const accessToken = tokenData.access_token; + const idToken = tokenData.id_token; + + if (!accessToken) { + return NextResponse.json({ error: "No access_token in response" }, { status: 502 }); + } + + // Fetch userinfo from Casdoor + const userRes = await fetch(CASDOOR_USERINFO_URL, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + let userInfo = null; + if (userRes.ok) { + userInfo = await userRes.json(); + } + + return NextResponse.json({ + success: true, + access_token: accessToken, + id_token: idToken, + token_type: tokenData.token_type || "Bearer", + expires_in: tokenData.expires_in || 3600, + refresh_token: tokenData.refresh_token || null, + user: userInfo + ? { + id: userInfo.sub || userInfo.id, + email: userInfo.email, + name: userInfo.name || userInfo.displayName || userInfo.preferred_username, + avatar: userInfo.avatar || userInfo.picture, + } + : null, + }); + } catch (error) { + console.error("[casdoor-oidc] Callback error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/auth/callback/page.tsx b/src/app/auth/callback/page.tsx index c15de60..357d08f 100644 --- a/src/app/auth/callback/page.tsx +++ b/src/app/auth/callback/page.tsx @@ -13,6 +13,7 @@ function AuthCallbackContent() { useEffect(() => { const token = searchParams.get("token"); + const code = searchParams.get("code"); const error = searchParams.get("error"); if (error) { @@ -21,17 +22,62 @@ function AuthCallbackContent() { return; } + // --- Casdoor OIDC flow: exchange authorization code for tokens --- + if (code) { + fetch(`/api/auth/casdoor-callback?code=${encodeURIComponent(code)}`) + .then((res) => res.json()) + .then((data) => { + if (data.error) { + setStatus("error"); + setErrorMsg(data.error); + return; + } + + // Store the access token + localStorage.setItem("flh_token", data.access_token); + if (data.id_token) { + localStorage.setItem("flh_id_token", data.id_token); + } + if (data.refresh_token) { + localStorage.setItem("flh_refresh_token", data.refresh_token); + } + if (data.user) { + localStorage.setItem("flh_user", JSON.stringify(data.user)); + } + + // Dispatch storage event so AuthContext picks it up + window.dispatchEvent( + new StorageEvent("storage", { + key: "flh_token", + newValue: data.access_token, + oldValue: null, + storageArea: localStorage, + }) + ); + + setStatus("success"); + + // Redirect to home + setTimeout(() => router.push("/"), 500); + }) + .catch((err) => { + console.error("[oidc-callback] Token exchange error:", err); + setStatus("error"); + setErrorMsg("Failed to complete authentication. Please try again."); + }); + return; + } + + // --- Legacy UmmahID flow: token passed directly in URL --- if (!token) { setStatus("error"); - setErrorMsg("No authentication token received."); + setErrorMsg("No authentication code 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", @@ -47,7 +93,6 @@ function AuthCallbackContent() { if (window.opener) { setTimeout(() => window.close(), 500); } else { - // Redirect to home setTimeout(() => router.push("/"), 500); } }, [searchParams, router]); diff --git a/src/app/auth/page.tsx b/src/app/auth/page.tsx index 357a76c..03836be 100644 --- a/src/app/auth/page.tsx +++ b/src/app/auth/page.tsx @@ -3,9 +3,18 @@ import { Suspense, useState, FormEvent, useEffect } from "react"; import { useAuth } from "@/lib/AuthContext"; import { useRouter, useSearchParams } from "next/navigation"; -import { Mail, ArrowLeft } from "lucide-react"; +import { Mail, ArrowLeft, Shield } from "lucide-react"; import ErrorFeedback from "@/components/ErrorFeedback"; +const getCasdoorUrl = () => { + const state = Math.random().toString(36).slice(2, 10); + const base = process.env.NEXT_PUBLIC_CASDOOR_SERVER_URL || "https://auth.falahos.my"; + const clientId = process.env.NEXT_PUBLIC_CASDOOR_CLIENT_ID || "272f91105da5cf984773"; + const redirect = process.env.NEXT_PUBLIC_CASDOOR_REDIRECT_URI || + "https://mobile2.falah-os.com/auth/callback"; + return `${base}/login/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirect)}&response_type=code&scope=openid+profile+email&state=${state}`; +}; + type AuthMode = "login" | "register"; type PageState = "select" | "email-form"; @@ -89,6 +98,22 @@ function AuthPageInner() { {/* Sign-in Methods */}