Files
solution-erp/fe-user/src/contexts/AuthContext.tsx
pqhuy1987 ea9ab5e352
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m43s
[CLAUDE] App+Infra+Api+FE: SignalR realtime notifications E2E
Clean-arch split:
- Application: IRealtimeNotifier (PushToUserAsync, abstraction)
- Api: NotificationHub (/hubs/notifications, [Authorize]) +
  SignalRNotifier impl với IHubContext<NotificationHub>, uses
  Clients.User(userId) (default provider resolves NameIdentifier="sub")
- Infrastructure: NotificationPushInterceptor — SaveChangesInterceptor
  capture Notification entities state=Added trong SavingChanges,
  push qua IRealtimeNotifier trong SavedChanges sau khi commit thành
  công. Zero caller changes — handlers chỉ cần db.Add(Notification).
  Attached vào ApplicationDbContext cùng với AuditingInterceptor.

Auth:
- JWT config thêm OnMessageReceived event: read ?access_token= từ
  query string khi path = /hubs/* (WebSockets không set headers).
- SignalRNotifier singleton (stateless, chỉ delegate IHubContext).

FE (both apps):
- @microsoft/signalr 8.0.7 vào package.json.
- lib/realtime.ts: singleton connection với lazy start + automatic
  reconnect [0,2s,5s,10s,15s] + accessTokenFactory lấy từ localStorage.
- NotificationBell: useEffect subscribe 'notification-created' khi
  isAuthenticated. On push: invalidate query + toast.message. Fallback
  polling giảm từ 30s → 60s (realtime cover gap).
- AuthContext.logout: dynamic import stopConnection() — avoid leaking
  auth'd socket across users.

Result: ERP-grade feel. Contract transition → Drafter nhận toast ngay
trong vòng 100-300ms (same-origin WebSocket), không cần F5 hay polling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 20:56:37 +07:00

83 lines
2.6 KiB
TypeScript

import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import { api, TOKEN_KEY, REFRESH_KEY, USER_KEY } from '@/lib/api'
import type { AuthResponse, LoginPayload, UserInfo } from '@/types/auth'
import type { MenuNode } from '@/types/menu'
type AuthContextValue = {
user: UserInfo | null
menu: MenuNode[]
isAuthenticated: boolean
isBootstrapping: boolean
login: (payload: LoginPayload) => Promise<void>
logout: () => void
refreshMenu: () => Promise<void>
}
const AuthContext = createContext<AuthContextValue | null>(null)
const MENU_KEY = 'solution-erp-user-menu'
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<UserInfo | null>(null)
const [menu, setMenu] = useState<MenuNode[]>([])
const [isBootstrapping, setIsBootstrapping] = useState(true)
async function loadMenu() {
try {
const res = await api.get<MenuNode[]>('/menus/me')
setMenu(res.data)
localStorage.setItem(MENU_KEY, JSON.stringify(res.data))
} catch {
// keep cached
}
}
useEffect(() => {
const token = localStorage.getItem(TOKEN_KEY)
const userRaw = localStorage.getItem(USER_KEY)
const menuRaw = localStorage.getItem(MENU_KEY)
if (token && userRaw) {
try {
setUser(JSON.parse(userRaw))
if (menuRaw) setMenu(JSON.parse(menuRaw))
loadMenu()
} catch {
localStorage.removeItem(USER_KEY)
localStorage.removeItem(MENU_KEY)
}
}
setIsBootstrapping(false)
}, [])
async function login(payload: LoginPayload) {
const res = await api.post<AuthResponse>('/auth/login', payload)
localStorage.setItem(TOKEN_KEY, res.data.accessToken)
localStorage.setItem(REFRESH_KEY, res.data.refreshToken)
localStorage.setItem(USER_KEY, JSON.stringify(res.data.user))
setUser(res.data.user)
await loadMenu()
}
function logout() {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(REFRESH_KEY)
localStorage.removeItem(USER_KEY)
localStorage.removeItem(MENU_KEY)
setUser(null)
setMenu([])
// Close realtime socket — avoid leaking auth'd connection across users
import('@/lib/realtime').then(m => m.stopConnection()).catch(() => {})
}
return (
<AuthContext.Provider value={{ user, menu, isAuthenticated: !!user, isBootstrapping, login, logout, refreshMenu: loadMenu }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used inside AuthProvider')
return ctx
}