sso setup

This commit is contained in:
shubham.goyal@brandtech.plus 2026-04-24 14:54:57 +05:30
parent 2964dbad2c
commit 6ecbd7d0ff
10 changed files with 256 additions and 19 deletions

View file

@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(npm install *)",
"Bash(npm run *)"
]
}
}

2
.gitignore vendored
View file

@ -11,6 +11,8 @@ node_modules
dist
dist-ssr
*.local
.env
.env.ms
# Editor directories and files
.vscode/*

72
App.tsx
View file

@ -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<SearchState & { country: string }>({
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<SearchState & { country: string }>({
product: '',
category: '',
country: '',
loading: false,
error: null
});
const [data, setData] = useState<DashboardData | null>(null);
const [hoveredClaim, setHoveredClaim] = useState<CompetitorClaim | null>(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 ? <Loader2 size={18} className="animate-spin" /> : 'Analyse'}
</button>
</form>
{/* User info + logout */}
{activeAccount && (
<div className="flex items-center gap-3 shrink-0 no-print">
<div className="hidden md:flex flex-col items-end">
<span className="text-[11px] font-black text-black uppercase tracking-tight leading-none">{activeAccount.name?.split(' ')[0]}</span>
<span className="text-[9px] font-bold text-gray-400 uppercase tracking-widest">{activeAccount.username}</span>
</div>
<button
onClick={handleLogout}
title="Sign out"
className="flex items-center justify-center w-10 h-10 bg-gray-50 hover:bg-black hover:text-white text-black rounded-xl border-2 border-black/8 hover:border-black transition-all active:scale-95"
>
<LogOut size={16} />
</button>
</div>
)}
</div>
</header>

17
authConfig.ts Normal file
View file

@ -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'],
};

36
components/AuthGuard.tsx Normal file
View file

@ -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 (
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="relative flex items-center justify-center w-12 h-12 bg-black rounded-xl shadow-lg animate-pulse">
<Compass size={24} className="text-[#FFD700]" />
</div>
<p className="text-[10px] font-black uppercase tracking-[0.3em] text-black/30">Authenticating...</p>
</div>
</div>
);
}
return (
<>
<AuthenticatedTemplate>{children}</AuthenticatedTemplate>
<UnauthenticatedTemplate><LoginPage /></UnauthenticatedTemplate>
</>
);
};
export default AuthGuard;

64
components/LoginPage.tsx Normal file
View file

@ -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 (
<div className="min-h-screen bg-white flex flex-col items-center justify-center selection:bg-[#FFD700]">
{/* Gold left accent */}
<div className="fixed top-0 left-0 w-1.5 h-full bg-[#FFD700] pointer-events-none opacity-30" />
<div className="fixed top-0 right-0 w-1.5 h-full bg-black pointer-events-none opacity-5" />
<div className="flex flex-col items-center gap-10 px-8 max-w-sm w-full">
{/* Logo */}
<div className="flex flex-col items-center gap-5">
<div className="relative flex items-center justify-center w-16 h-16 bg-black rounded-2xl shadow-2xl">
<Compass size={32} className="text-[#FFD700]" />
<div className="absolute top-2.5 right-2.5">
<div className="w-2 h-2 bg-white rounded-full animate-pulse" />
</div>
</div>
<div className="text-center">
<h1 className="font-black tracking-tight text-xl text-black uppercase leading-tight">APAC Strategy</h1>
<h1 className="font-black tracking-tight text-xl text-black uppercase leading-tight">& Insights Engine</h1>
</div>
<div className="w-12 h-0.5 bg-[#FFD700]" />
<p className="text-[11px] font-bold text-gray-400 uppercase tracking-[0.2em] text-center">
AI-Powered Market Intelligence
</p>
</div>
{/* Sign in box */}
<div className="w-full border-2 border-black/8 rounded-3xl p-8 flex flex-col items-center gap-6 shadow-[0_20px_60px_-20px_rgba(0,0,0,0.1)]">
<p className="text-[11px] font-black uppercase tracking-widest text-black/40">
Sign in to continue
</p>
<button
onClick={handleLogin}
className="w-full flex items-center justify-center gap-3 bg-black text-white hover:bg-[#FFD700] hover:text-black px-6 py-4 rounded-xl font-black uppercase tracking-[0.15em] text-[11px] transition-all active:scale-95 border-2 border-black"
>
Sign in
</button>
<p className="text-[9px] text-gray-400 font-bold uppercase tracking-widest text-center leading-relaxed">
Access restricted to authorised users
</p>
</div>
<p className="text-[9px] text-gray-300 font-bold uppercase tracking-widest">
© BrandTech Group
</p>
</div>
</div>
);
};
export default LoginPage;

View file

@ -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(
<React.StrictMode>
<App />
<MsalProvider instance={msalInstance}>
<AuthGuard>
<App />
</AuthGuard>
</MsalProvider>
</React.StrictMode>
);

36
package-lock.json generated
View file

@ -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",

View file

@ -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": {

View file

@ -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<string, string> {
try {
const content = fs.readFileSync(filePath, 'utf-8');
const result: Record<string, string> = {};
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: {