Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
256 changes: 256 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
"axios": "^1.18.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8",
"keycloak-js": "^26.2.4",
"lucide-react": "^1.18.0",
"radix-ui": "^1.5.0",
"react": "^19.2.6",
Expand Down
56 changes: 53 additions & 3 deletions src/components/shared/AppLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { useState } from 'react'
import { useState, useRef, useEffect } from 'react'
import type { LucideIcon } from 'lucide-react'
import {
ArrowLeftRight,
Expand All @@ -9,6 +9,7 @@ import {
HelpCircle,
LayoutDashboard,
Landmark,
LogOut,
Search,
Settings,
Shield,
Expand All @@ -18,6 +19,7 @@ import {
Users,
} from 'lucide-react'
import { APP_NAME, DEFAULT_TENANT, TENANTS } from '@/config/constants'
import { useAuth } from '@/lib/keycloak/useAuth'

interface NavItem {
label: string
Expand All @@ -42,15 +44,29 @@ const navItems: NavItem[] = [
export default function AppLayout() {
const location = useLocation()
const navigate = useNavigate()
const { user, logout } = useAuth()
const [tenant, setTenant] = useState(
localStorage.getItem('tenant') ?? DEFAULT_TENANT
)
const [avatarOpen, setAvatarOpen] = useState(false)
const avatarRef = useRef<HTMLDivElement>(null)

function handleTenantChange(value: string) {
setTenant(value)
localStorage.setItem('tenant', value)
}

// Close dropdown on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (avatarRef.current && !avatarRef.current.contains(e.target as Node)) {
setAvatarOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [])

function isActive(item: NavItem) {
return item.exact
? location.pathname === item.path
Expand Down Expand Up @@ -138,8 +154,42 @@ export default function AppLayout() {
<HelpCircle size={18} />
</button>

<div className="w-8 h-8 rounded-full bg-[#1565C0] flex items-center justify-center">
<User size={15} className="text-white" />
{/* Avatar dropdown */}
<div className="relative" ref={avatarRef}>
<button
type="button"
aria-label="User menu"
onClick={() => setAvatarOpen((v) => !v)}
className="flex items-center gap-2 rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1565C0]"
>
<div className="w-8 h-8 rounded-full bg-[#1565C0] flex items-center justify-center">
<User size={15} className="text-white" />
</div>
{user?.name && (
<span className="text-sm text-gray-700 font-medium max-w-28 truncate hidden lg:block">
{user.name}
</span>
)}
</button>

{avatarOpen && (
<div className="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-100 py-1 z-50">
{user && (
<div className="px-4 py-3 border-b border-gray-100">
<p className="text-sm font-medium text-gray-800 truncate">{user.name}</p>
<p className="text-xs text-gray-500 truncate">{user.email}</p>
</div>
)}
<button
type="button"
onClick={() => { setAvatarOpen(false); logout() }}
className="flex items-center gap-2 w-full px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
>
<LogOut size={14} />
Sign Out
</button>
</div>
)}
</div>
</header>

Expand Down
8 changes: 7 additions & 1 deletion src/lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ const apiClient = axios.create({

apiClient.interceptors.request.use((config) => {
const tenant = localStorage.getItem('tenant') || 'greenbank'
config.headers.set('X-TenantId', tenant)
config.headers.set('Platform-TenantId', tenant)

const token = localStorage.getItem('kc_token')
if (token) {
config.headers.set('Authorization', `Bearer ${token}`)
}

return config
})

Expand Down
26 changes: 26 additions & 0 deletions src/lib/exportCsv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export function exportCsv(rows: Record<string, unknown>[], filename: string) {
if (rows.length === 0) return
const headers = Object.keys(rows[0])
const escape = (v: unknown) => {
const s = v == null ? '' : String(v)
return s.includes(',') || s.includes('"') || s.includes('\n')
? `"${s.replace(/"/g, '""')}"`
: s
}
const csv = [
headers.join(','),
...rows.map((r) => headers.map((h) => escape(r[h])).join(',')),
].join('\n')

const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}

export function csvDate() {
return new Date().toISOString().slice(0, 10)
}
30 changes: 30 additions & 0 deletions src/lib/exportPdf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { jsPDF } from 'jspdf'
import autoTable from 'jspdf-autotable'
import { csvDate } from './exportCsv'

export function exportPdf(
title: string,
headers: string[],
rows: (string | number)[][],
filename: string,
) {
const doc = new jsPDF({ orientation: 'landscape', unit: 'pt', format: 'a4' })
doc.setFontSize(13)
doc.text(title, 40, 36)
doc.setFontSize(9)
doc.setTextColor(120)
doc.text(`Generated ${csvDate()}`, 40, 52)

autoTable(doc, {
head: [headers],
body: rows as string[][],
startY: 64,
styles: { fontSize: 8, cellPadding: 4 },
headStyles: { fillColor: [21, 101, 192], textColor: 255, fontStyle: 'bold' },
alternateRowStyles: { fillColor: [245, 247, 250] },
})

doc.save(filename)
}

export { csvDate }
90 changes: 90 additions & 0 deletions src/lib/keycloak/KeycloakProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import keycloak from './keycloak'

interface KeycloakContextValue {
keycloak: typeof keycloak
authenticated: boolean
token: string | undefined
logout: () => void
}

const KeycloakContext = createContext<KeycloakContextValue | null>(null)

export function useKeycloak() {
const ctx = useContext(KeycloakContext)
if (!ctx) throw new Error('useKeycloak must be used inside KeycloakProvider')
return ctx
}

export default function KeycloakProvider({ children }: { children: ReactNode }) {
const navigate = useNavigate()
const [initialized, setInitialized] = useState(false)
const [authenticated, setAuthenticated] = useState(false)

useEffect(() => {
if (keycloak.didInitialize) {
setAuthenticated(keycloak.authenticated ?? false)
if (keycloak.token) localStorage.setItem('kc_token', keycloak.token)
setInitialized(true)
return
}

keycloak
.init({
onLoad: 'check-sso',
checkLoginIframe: false,
pkceMethod: 'S256',
})
.then((auth) => {
console.log('Auth status:', auth)
setAuthenticated(auth)
if (auth && keycloak.token) {
localStorage.setItem('kc_token', keycloak.token)
} else {
localStorage.removeItem('kc_token')
navigate('/login', { replace: true })
}
setInitialized(true)
})
.catch((err) => {
console.error('Keycloak init error:', err)
setInitialized(true)
navigate('/login', { replace: true })
})

keycloak.onTokenExpired = () => {
keycloak.updateToken(60).then((refreshed) => {
if (refreshed && keycloak.token) {
localStorage.setItem('kc_token', keycloak.token)
}
}).catch(() => {
localStorage.removeItem('kc_token')
keycloak.logout()
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

function logout() {
localStorage.removeItem('kc_token')
keycloak.logout({ redirectUri: `${window.location.origin}/login` })
}

if (!initialized) {
return (
<div className="min-h-screen flex items-center justify-center bg-[#F5F7FA]">
<div className="flex flex-col items-center gap-4">
<div className="w-8 h-8 rounded-full border-2 border-[#1565C0] border-t-transparent animate-spin" />
<p className="text-sm text-gray-500">Initializing session…</p>
</div>
</div>
)
}

return (
<KeycloakContext.Provider value={{ keycloak, authenticated, token: keycloak.token, logout }}>
{children}
</KeycloakContext.Provider>
)
}
9 changes: 9 additions & 0 deletions src/lib/keycloak/keycloak.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Keycloak from 'keycloak-js'

const keycloak = new Keycloak({
url: import.meta.env.VITE_KEYCLOAK_URL,
realm: import.meta.env.VITE_KEYCLOAK_REALM,
clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID,
})

export default keycloak
35 changes: 35 additions & 0 deletions src/lib/keycloak/useAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useKeycloak } from './KeycloakProvider'

interface AuthUser {
name: string
email: string
username: string
}

interface UseAuthReturn {
authenticated: boolean
token: string | undefined
user: AuthUser | null
logout: () => void
hasRole: (role: string) => boolean
}

export function useAuth(): UseAuthReturn {
const { keycloak, authenticated, token, logout } = useKeycloak()

const tokenParsed = keycloak.tokenParsed as Record<string, unknown> | undefined

const user: AuthUser | null = tokenParsed
? {
name: (tokenParsed['name'] as string) ?? (tokenParsed['preferred_username'] as string) ?? '',
email: (tokenParsed['email'] as string) ?? '',
username: (tokenParsed['preferred_username'] as string) ?? '',
}
: null

function hasRole(role: string): boolean {
return keycloak.hasRealmRole(role) || keycloak.hasResourceRole(role)
}

return { authenticated, token, user, logout, hasRole }
}
47 changes: 34 additions & 13 deletions src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import { createBrowserRouter, RouterProvider, Outlet } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import './index.css'
import KeycloakProvider from '@/lib/keycloak/KeycloakProvider'
import AppLayout from '@/components/shared/AppLayout'
import SplashScreen from '@/pages/SplashScreen'
import LoginPage from '@/pages/LoginPage'
Expand All @@ -16,28 +17,48 @@ import RBACConfig from '@/pages/RBACConfig'
import Reporting from '@/pages/Reporting'
import Visualizations from '@/pages/Visualizations'
import NotFound from '@/pages/NotFound'
import AccountMapperSelfService from '@/pages/AccountMapperSelfService'

// Thin root wrapper: supplies KeycloakProvider inside the router tree
// so useNavigate is available to the provider.
function AuthRoot() {
return (
<KeycloakProvider>
<Outlet />
</KeycloakProvider>
)
}

const queryClient = new QueryClient()

const router = createBrowserRouter([
// Public routes — no Keycloak wrapper (Keycloak handles its own redirect)
{ path: '/splash', element: <SplashScreen /> },
{ path: '/login', element: <LoginPage /> },
{ path: '/account-mapper/self-service', element: <AccountMapperSelfService /> },

// Protected routes — wrapped by KeycloakProvider
{
path: '/',
element: <AppLayout />,
element: <AuthRoot />,
children: [
{ index: true, element: <Dashboard /> },
{ path: 'payment-hub', element: <PaymentHub /> },
{ path: 'vouchers', element: <Vouchers /> },
{ path: 'account-mapper', element: <AccountMapper /> },
{ path: 'g2p-config', element: <G2PConfig /> },
{ path: 'settings', element: <Settings /> },
{ path: 'rbac', element: <RBACConfig /> },
{ path: 'reporting', element: <Reporting /> },
{ path: 'visualizations', element: <Visualizations /> },
{
path: '/',
element: <AppLayout />,
children: [
{ index: true, element: <Dashboard /> },
{ path: 'payment-hub', element: <PaymentHub /> },
{ path: 'vouchers', element: <Vouchers /> },
{ path: 'account-mapper', element: <AccountMapper /> },
{ path: 'g2p-config', element: <G2PConfig /> },
{ path: 'settings', element: <Settings /> },
{ path: 'rbac', element: <RBACConfig /> },
{ path: 'reporting', element: <Reporting /> },
{ path: 'visualizations', element: <Visualizations /> },
],
},
{ path: '*', element: <NotFound /> },
],
},
{ path: '*', element: <NotFound /> },
])

createRoot(document.getElementById('root')!).render(
Expand Down
Loading
Loading