diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..db6f35c --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(npm install *)", + "Bash(npm run *)" + ] + } +} diff --git a/.gitignore b/.gitignore index a547bf3..0489b5c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ node_modules dist dist-ssr *.local +.env +.env.ms # Editor directories and files .vscode/* diff --git a/App.tsx b/App.tsx index 76af19d..640a0e1 100644 --- a/App.tsx +++ b/App.tsx @@ -1,13 +1,16 @@ import React, { useState, useEffect } from 'react'; -import { - Search, FileDown, Info, LayoutGrid, Zap, Sparkles, Loader2, - TrendingUp, TrendingDown, Minus, Target, Eye, Layers, - BarChart3, Cloud, Activity, Globe, Rocket, ShieldCheck, - Target as TargetIcon, UserCheck, MessageSquare, Copy, +import { + Search, FileDown, Info, LayoutGrid, Zap, Sparkles, Loader2, + TrendingUp, TrendingDown, Minus, Target, Eye, Layers, + BarChart3, Cloud, Activity, Globe, Rocket, ShieldCheck, + Target as TargetIcon, UserCheck, MessageSquare, Copy, ArrowUpRight, CheckCircle2, Lightbulb, AlertTriangle, Clock, - Users, Newspaper, Smile, Frown, Meh, Star, Compass + Users, Newspaper, Smile, Frown, Meh, Star, Compass, LogOut } from 'lucide-react'; +import { useMsal } from '@azure/msal-react'; +import { InteractionRequiredAuthError } from '@azure/msal-browser'; +import { loginRequest } from './authConfig'; import { DashboardData, SearchState, CompetitorClaim, MarketMetric, WhitespaceItem, Creator, NewsSentiment, RadarMetric } from './types'; import { fetchMarketInsights } from './geminiService'; @@ -60,12 +63,19 @@ const Logo = () => ( ); const App: React.FC = () => { - const [search, setSearch] = useState({ - product: '', - category: '', - country: '', - loading: false, - error: null + const { instance, accounts } = useMsal(); + const activeAccount = accounts[0]; + + const handleLogout = () => { + instance.logoutRedirect({ postLogoutRedirectUri: process.env.AZURE_REDIRECT_URI }); + }; + + const [search, setSearch] = useState({ + product: '', + category: '', + country: '', + loading: false, + error: null }); const [data, setData] = useState(null); const [hoveredClaim, setHoveredClaim] = useState(null); @@ -76,10 +86,23 @@ const App: React.FC = () => { e?.preventDefault(); if (!search.product || !search.category || search.loading) return; + // Validate session before running the search + try { + await instance.acquireTokenSilent({ ...loginRequest, account: activeAccount }); + } catch (err) { + if (err instanceof InteractionRequiredAuthError) { + setSearch(prev => ({ ...prev, error: 'Your session has expired. Redirecting you to sign in again...' })); + setTimeout(() => instance.loginRedirect(loginRequest), 2500); + return; + } + // Transient silent-token error — log but don't block the search + console.warn('Silent token refresh failed (non-interaction):', err); + } + setSearch(prev => ({ ...prev, loading: true, error: null })); setData(null); setLoadMessage('Grounded Search Active...'); - + const messages = [ 'Scanning Regional Dynamics...', 'Aggregating Competitor Claims...', @@ -99,9 +122,9 @@ const App: React.FC = () => { setLastUpdated(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true })); } catch (err: any) { console.error(err); - setSearch(prev => ({ - ...prev, - error: err.message || 'System busy. Please try again.' + setSearch(prev => ({ + ...prev, + error: err.message || 'System busy. Please try again.' })); } finally { clearInterval(interval); @@ -201,6 +224,23 @@ const App: React.FC = () => { {search.loading ? : 'Analyse'} + + {/* User info + logout */} + {activeAccount && ( +
+
+ {activeAccount.name?.split(' ')[0]} + {activeAccount.username} +
+ +
+ )} diff --git a/authConfig.ts b/authConfig.ts new file mode 100644 index 0000000..809002f --- /dev/null +++ b/authConfig.ts @@ -0,0 +1,17 @@ +import { Configuration } from '@azure/msal-browser'; + +export const msalConfig: Configuration = { + auth: { + clientId: process.env.AZURE_CLIENT_ID!, + authority: `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}`, + redirectUri: process.env.AZURE_REDIRECT_URI!, + postLogoutRedirectUri: process.env.AZURE_REDIRECT_URI!, + }, + cache: { + cacheLocation: 'sessionStorage', + }, +}; + +export const loginRequest = { + scopes: ['openid', 'profile', 'email', 'User.Read'], +}; diff --git a/components/AuthGuard.tsx b/components/AuthGuard.tsx new file mode 100644 index 0000000..73d3f07 --- /dev/null +++ b/components/AuthGuard.tsx @@ -0,0 +1,36 @@ +import React, { useEffect } from 'react'; +import { AuthenticatedTemplate, UnauthenticatedTemplate, useMsal } from '@azure/msal-react'; +import { InteractionStatus } from '@azure/msal-browser'; +import { Compass } from 'lucide-react'; +import LoginPage from './LoginPage'; + +const AuthGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { instance, inProgress } = useMsal(); + + useEffect(() => { + // Handle redirect response on app load + instance.handleRedirectPromise().catch(console.error); + }, [instance]); + + if (inProgress === InteractionStatus.Startup || inProgress === InteractionStatus.HandleRedirect) { + return ( +
+
+
+ +
+

Authenticating...

+
+
+ ); + } + + return ( + <> + {children} + + + ); +}; + +export default AuthGuard; diff --git a/components/LoginPage.tsx b/components/LoginPage.tsx new file mode 100644 index 0000000..8e944a9 --- /dev/null +++ b/components/LoginPage.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { useMsal } from '@azure/msal-react'; +import { Compass } from 'lucide-react'; +import { loginRequest } from '../authConfig'; + +const LoginPage: React.FC = () => { + const { instance } = useMsal(); + + const handleLogin = () => { + instance.loginRedirect(loginRequest); + }; + + return ( +
+ {/* Gold left accent */} +
+
+ +
+ {/* Logo */} +
+
+ +
+
+
+
+
+

APAC Strategy

+

& Insights Engine

+
+
+

+ AI-Powered Market Intelligence +

+
+ + {/* Sign in box */} +
+

+ Sign in to continue +

+ + + +

+ Access restricted to authorised users +

+
+ +

+ © BrandTech Group +

+
+
+ ); +}; + +export default LoginPage; diff --git a/index.tsx b/index.tsx index aaa0c6e..ed122df 100644 --- a/index.tsx +++ b/index.tsx @@ -1,7 +1,13 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; +import { MsalProvider } from '@azure/msal-react'; +import { PublicClientApplication } from '@azure/msal-browser'; +import { msalConfig } from './authConfig'; import App from './App'; +import AuthGuard from './components/AuthGuard'; + +const msalInstance = new PublicClientApplication(msalConfig); const rootElement = document.getElementById('root'); if (!rootElement) { @@ -11,6 +17,10 @@ if (!rootElement) { const root = ReactDOM.createRoot(rootElement); root.render( - + + + + + ); diff --git a/package-lock.json b/package-lock.json index b11970d..53f3a4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "oho.2---apac-strategy-dashboard", "version": "0.0.0", "dependencies": { + "@azure/msal-browser": "^5.8.0", + "@azure/msal-react": "^5.3.1", "@google/genai": "^1.37.0", "lucide-react": "^0.562.0", "react": "^19.2.3", @@ -20,6 +22,40 @@ "vite": "^6.2.0" } }, + "node_modules/@azure/msal-browser": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.8.0.tgz", + "integrity": "sha512-X7IZV77bN56l7sbLjkcbQJX1t3U4tgxqztDr/XFbUcUfKk+z2FavcLgKP+OYUNj0wl/pEEtV9lldW9siY8BuHQ==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.5.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.5.1.tgz", + "integrity": "sha512-WS9w9SfI8SEYO7mTnxGeZ3UwQfhAVYCWglYF2/7GNx3ioHiAs2gPkl9eSwVs8cPrmiGh+zi9ai/OOKoq4cyzDw==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-react": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@azure/msal-react/-/msal-react-5.3.1.tgz", + "integrity": "sha512-yR6BFVPyufFDg9qmtCxqpqMH3axxz/9hBL5ZO7zqMH9X9dZvjhcuAO5BL6zhS33VVPpNEZhJflU0LW1NHI2lIw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@azure/msal-browser": "^5.8.0", + "react": "^16.8.0 || ^17 || ^18 || ^19.2.1" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", diff --git a/package.json b/package.json index 5200d5e..6e34dc7 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,11 @@ "lint": "tsc --noEmit" }, "dependencies": { + "@azure/msal-browser": "^5.8.0", + "@azure/msal-react": "^5.3.1", "@google/genai": "^1.37.0", - "react": "^19.2.3", "lucide-react": "^0.562.0", + "react": "^19.2.3", "react-dom": "^19.2.3" }, "devDependencies": { diff --git a/vite.config.ts b/vite.config.ts index bd3794e..2e42eec 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,9 +1,28 @@ import path from 'path'; +import fs from 'fs'; import { defineConfig, loadEnv } from 'vite'; import react from '@vitejs/plugin-react'; +function parseDotEnv(filePath: string): Record { + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const result: Record = {}; + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) continue; + result[trimmed.slice(0, eqIdx).trim()] = trimmed.slice(eqIdx + 1).trim(); + } + return result; + } catch { + return {}; + } +} + export default defineConfig(({ mode }) => { const env = loadEnv(mode, '.', ''); + const msEnv = parseDotEnv('.env.ms'); return { base: '/apac-strategy-dashboard/', server: { @@ -13,7 +32,10 @@ export default defineConfig(({ mode }) => { plugins: [react()], define: { 'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY), - 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY) + 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY), + 'process.env.AZURE_TENANT_ID': JSON.stringify(msEnv.AZURE_TENANT_ID), + 'process.env.AZURE_CLIENT_ID': JSON.stringify(msEnv.AZURE_CLIENT_ID), + 'process.env.AZURE_REDIRECT_URI': JSON.stringify(msEnv.AZURE_REDIRECT_URI), }, resolve: { alias: {