All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m43s
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>
83 lines
2.6 KiB
TypeScript
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
|
|
}
|