sso setup
This commit is contained in:
parent
2964dbad2c
commit
6ecbd7d0ff
10 changed files with 256 additions and 19 deletions
8
.claude/settings.local.json
Normal file
8
.claude/settings.local.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm install *)",
|
||||
"Bash(npm run *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -11,6 +11,8 @@ node_modules
|
|||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
.env.ms
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
|
|
|||
72
App.tsx
72
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<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
17
authConfig.ts
Normal 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
36
components/AuthGuard.tsx
Normal 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
64
components/LoginPage.tsx
Normal 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;
|
||||
12
index.tsx
12
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(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<MsalProvider instance={msalInstance}>
|
||||
<AuthGuard>
|
||||
<App />
|
||||
</AuthGuard>
|
||||
</MsalProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
|
|
|||
36
package-lock.json
generated
36
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue