From 10494ae0e462277c4a2975820ddc2ae2328a502d Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Thu, 12 Mar 2026 11:15:44 -0700 Subject: [PATCH] =?UTF-8?q?feat(auth):=20SmartAuth=20tracker-web=20?= =?UTF-8?q?=E2=80=94=20OAuth=20proxy,=20MFA=20verify,=20login=20page=20wit?= =?UTF-8?q?h=20Google=20Sign-In?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add OAuth proxy route with productId forwarding via getRequestProductId - Add MFA verify proxy route - Update login page with Google Sign-In button (env-gated) and MFA challenge flow - Fix completeAuth to avoid redundant router.push before window.location.href - Add NEXT_PUBLIC_GOOGLE_CLIENT_ID to .env.example - Add MessageEvent to ESLint globals for popup message handler --- dashboards/tracker-web/.env.example | 3 + dashboards/tracker-web/eslint.config.js | 1 + .../src/app/api/auth/mfa/verify/route.ts | 29 ++ .../app/api/auth/oauth/[provider]/route.ts | 41 +++ dashboards/tracker-web/src/app/login/page.tsx | 248 +++++++++++++++++- 5 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 dashboards/tracker-web/src/app/api/auth/mfa/verify/route.ts create mode 100644 dashboards/tracker-web/src/app/api/auth/oauth/[provider]/route.ts diff --git a/dashboards/tracker-web/.env.example b/dashboards/tracker-web/.env.example index 35d465f8..672076dd 100644 --- a/dashboards/tracker-web/.env.example +++ b/dashboards/tracker-web/.env.example @@ -14,6 +14,9 @@ PLATFORM_API_URL=http://localhost:4003 # ── Auth (JWT) ── JWT_SECRET= +# ── SmartAuth: OAuth (optional — enables social login buttons) ── +NEXT_PUBLIC_GOOGLE_CLIENT_ID= + # ── Azure Key Vault (optional — resolves secrets at startup) ── AZURE_KEYVAULT_URL= diff --git a/dashboards/tracker-web/eslint.config.js b/dashboards/tracker-web/eslint.config.js index ae53d622..ff91dd3f 100644 --- a/dashboards/tracker-web/eslint.config.js +++ b/dashboards/tracker-web/eslint.config.js @@ -67,6 +67,7 @@ export default [ HTMLDivElement: 'readonly', HTMLInputElement: 'readonly', HTMLButtonElement: 'readonly', + MessageEvent: 'readonly', }, }, plugins: { diff --git a/dashboards/tracker-web/src/app/api/auth/mfa/verify/route.ts b/dashboards/tracker-web/src/app/api/auth/mfa/verify/route.ts new file mode 100644 index 00000000..721bb143 --- /dev/null +++ b/dashboards/tracker-web/src/app/api/auth/mfa/verify/route.ts @@ -0,0 +1,29 @@ +/** + * Proxy MFA verification to platform-service. + * POST /api/auth/mfa/verify { challengeToken, code, method } + */ + +import { NextRequest, NextResponse } from 'next/server'; + +const PLATFORM_API = process.env.PLATFORM_API_URL || 'http://localhost:4003'; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const res = await fetch(`${PLATFORM_API}/api/auth/mfa/verify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) { + return NextResponse.json( + { error: data.error || 'MFA verification failed' }, + { status: res.status } + ); + } + return NextResponse.json(data); + } catch { + return NextResponse.json({ error: 'Platform service unavailable' }, { status: 502 }); + } +} diff --git a/dashboards/tracker-web/src/app/api/auth/oauth/[provider]/route.ts b/dashboards/tracker-web/src/app/api/auth/oauth/[provider]/route.ts new file mode 100644 index 00000000..eb6b61c6 --- /dev/null +++ b/dashboards/tracker-web/src/app/api/auth/oauth/[provider]/route.ts @@ -0,0 +1,41 @@ +/** + * Proxy OAuth login to platform-service. + * POST /api/auth/oauth/:provider { idToken } + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getRequestProductId } from '@/lib/product-config'; + +const PLATFORM_API = process.env.PLATFORM_API_URL || 'http://localhost:4003'; + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ provider: string }> } +) { + try { + const { provider } = await params; + const body = await req.json(); + + if (!body.idToken) { + return NextResponse.json({ error: 'idToken required' }, { status: 400 }); + } + + const res = await fetch(`${PLATFORM_API}/api/auth/oauth/${provider}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...body, productId: getRequestProductId(req) }), + }); + + const data = await res.json(); + if (!res.ok) { + return NextResponse.json( + { error: data.error || `OAuth ${provider} login failed` }, + { status: res.status } + ); + } + + return NextResponse.json(data); + } catch { + return NextResponse.json({ error: 'Platform service unavailable' }, { status: 502 }); + } +} diff --git a/dashboards/tracker-web/src/app/login/page.tsx b/dashboards/tracker-web/src/app/login/page.tsx index f4392f2a..05094095 100644 --- a/dashboards/tracker-web/src/app/login/page.tsx +++ b/dashboards/tracker-web/src/app/login/page.tsx @@ -1,9 +1,11 @@ 'use client'; -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/lib/auth-context'; +const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || ''; + export default function LoginPage() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); @@ -12,6 +14,42 @@ export default function LoginPage() { const { login } = useAuth(); const router = useRouter(); + // MFA state + const [mfaChallenge, setMfaChallenge] = useState(null); + const [mfaMethods, setMfaMethods] = useState([]); + const [mfaCode, setMfaCode] = useState(''); + const [useRecovery, setUseRecovery] = useState(false); + + const completeAuth = useCallback( + (data: { + accessToken: string; + user: { id: string; email: string; role: string; displayName: string }; + }) => { + localStorage.setItem('tracker_token', data.accessToken); + // Force full reload so auth-context re-reads token from localStorage + window.location.href = '/dashboard'; + }, + [] + ); + + const handleLoginResponse = useCallback( + (data: Record) => { + if (data.mfaRequired) { + setMfaChallenge(data.mfaChallenge as string); + setMfaMethods((data.methods as string[]) || []); + setError(''); + return; + } + completeAuth( + data as { + accessToken: string; + user: { id: string; email: string; role: string; displayName: string }; + } + ); + }, + [completeAuth] + ); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); @@ -26,6 +64,178 @@ export default function LoginPage() { } }; + const handleGoogleSignIn = useCallback(async () => { + setError(''); + setLoading(true); + try { + const google = (window as unknown as Record).google as + | { + accounts: { + id: { + initialize: (config: Record) => void; + prompt: () => void; + }; + }; + } + | undefined; + + if (google) { + google.accounts.id.initialize({ + client_id: GOOGLE_CLIENT_ID, + callback: async (response: { credential: string }) => { + const res = await fetch('/api/auth/oauth/google', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ idToken: response.credential }), + }); + const data = await res.json(); + setLoading(false); + if (!res.ok) { + setError(data.error || 'Google sign-in failed'); + return; + } + handleLoginResponse(data); + }, + }); + google.accounts.id.prompt(); + } else { + // Fallback: popup + const w = 500, + h = 600; + const left = window.screenX + (window.outerWidth - w) / 2; + const top = window.screenY + (window.outerHeight - h) / 2; + const popup = window.open( + `https://accounts.google.com/o/oauth2/v2/auth?` + + `client_id=${GOOGLE_CLIENT_ID}&` + + `redirect_uri=${encodeURIComponent(window.location.origin + '/api/auth/callback/google')}&` + + `response_type=id_token&scope=openid email profile&nonce=${crypto.randomUUID()}`, + 'google-signin', + `width=${w},height=${h},left=${left},top=${top}` + ); + const handler = (event: MessageEvent) => { + if (event.origin === window.location.origin && event.data?.idToken) { + window.removeEventListener('message', handler); + popup?.close(); + fetch('/api/auth/oauth/google', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ idToken: event.data.idToken }), + }) + .then(r => r.json()) + .then(data => { + setLoading(false); + if (data.error) { + setError(data.error); + return; + } + handleLoginResponse(data); + }); + } + }; + window.addEventListener('message', handler); + } + } catch { + setError('Google sign-in failed'); + setLoading(false); + } + }, [handleLoginResponse]); + + const handleMfaVerify = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + try { + const res = await fetch('/api/auth/mfa/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challengeToken: mfaChallenge, + code: mfaCode, + method: useRecovery ? 'recovery' : 'totp', + }), + }); + const data = await res.json(); + if (!res.ok) { + setError(data.error || 'Verification failed'); + return; + } + completeAuth(data); + } catch { + setError('Service unavailable'); + } finally { + setLoading(false); + } + }; + + // MFA challenge view + if (mfaChallenge) { + return ( +
+
+
+

Two-Factor Auth

+

+ {useRecovery ? 'Enter a recovery code' : 'Enter your authentication code'} +

+ {mfaMethods.length > 0 && ( +

Methods: {mfaMethods.join(', ')}

+ )} +
+
+ {error && ( +
+ {error} +
+ )} + setMfaCode(e.target.value)} + required + maxLength={useRecovery ? 20 : 6} + className="w-full rounded-md border border-input bg-background px-3 py-2 text-center text-lg font-mono tracking-widest outline-none ring-ring focus:ring-2" + autoFocus + /> + +
+ + +
+
+
+
+ ); + } + return (
@@ -81,6 +291,42 @@ export default function LoginPage() { + {GOOGLE_CLIENT_ID && ( + <> +
+
+ or +
+
+ + + )} +

Uses platform-service credentials