Scripts moi (PowerShell admin trên VPS Windows Server):
- setup-sql-db.ps1: tao DB SolutionErp + grant db_owner cho vrapp (user shared voi VIETREPORT). Idempotent.
- setup-iis-sites.ps1: app pool SolutionErp-Api (NoManagedCode + AlwaysRunning + no idle) + 3 site (SolutionErp-Api/Admin/User) voi host header, C:\inetpub\solution-erp\{api,fe-admin,fe-user,logs,uploads}. Placeholder index.html + SPA web.config voi URL rewrite fallback + security headers. Firewall rule. ACL grant AppPool identity Modify. Naming prefix SolutionErp-* tranh conflict VIETREPORT.
- setup-ssl.ps1: download win-acme v2.2.9 → issue cert Let's Encrypt 3 domain (api/admin/user.huypham.vn) qua HTTP-01 challenge + auto install IIS binding + HTTP→HTTPS redirect + scheduled task 90d renew.
- setup-gitea-runner.ps1: download act_runner.exe → register voi Gitea git.baocaogiaoduc.vn, install Windows service, labels windows-latest,self-hosted,windows,x64 (cho phep share voi VIETREPORT).
FE production config:
- fe-admin/.env.production + fe-user/.env.production: VITE_API_BASE_URL=https://api.huypham.vn
- fe-admin/src/lib/api.ts + fe-user/src/lib/api.ts: BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? '') + '/api'
- Dev: empty prefix → /api qua Vite proxy :5443
- Prod: https://api.huypham.vn/api (cross-origin CORS da config AllowedOrigins)
Docs:
- docs/guides/vps-setup.md MOI (master runbook): prereq, 4 script chay theo thu tu, set 5 Gitea secrets, first deploy, appsettings.Production.json pattern (file hoac user-secrets), smoke test 3 curl, post go-live checklist (doi admin password, rotate secrets chat-exposed, backup schedule, disable Swagger prod, monitor logs), table co-existence VIETREPORT
- CLAUDE.md root: add vps-setup.md reference
Gitea repo da setup (extern):
- https://git.baocaogiaoduc.vn/vietreport-admin/solution-erp (private)
- Secrets set via API: IIS_HOST=103.124.94.38, IIS_USER=Administrator, DB_CONNECTION (voi vrapp password), JWT_SECRET placeholder
- CON THIEU: IIS_PASSWORD (Windows admin — user cung cap), JWT_SECRET real value (64-char tu vps-jwt-key.txt — user update qua Gitea UI)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
103 lines
3.0 KiB
TypeScript
103 lines
3.0 KiB
TypeScript
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
|
|
|
export const TOKEN_KEY = 'solution-erp-admin-token'
|
|
export const REFRESH_KEY = 'solution-erp-admin-refresh'
|
|
export const USER_KEY = 'solution-erp-admin-user'
|
|
|
|
// Dev: Vite proxy /api → :5443 (vite.config.ts)
|
|
// Prod: VITE_API_BASE_URL = https://api.huypham.vn (env.production)
|
|
const BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? '') + '/api'
|
|
|
|
export const api = axios.create({
|
|
baseURL: BASE_URL,
|
|
timeout: 30000,
|
|
})
|
|
|
|
api.interceptors.request.use(config => {
|
|
const token = localStorage.getItem(TOKEN_KEY)
|
|
if (token) config.headers.Authorization = `Bearer ${token}`
|
|
return config
|
|
})
|
|
|
|
// Refresh token flow: khi 401 → thử refresh → retry request gốc. Queue các request song song
|
|
// để chỉ 1 refresh call chạy, các request khác chờ token mới.
|
|
type QueueItem = {
|
|
resolve: (value: unknown) => void
|
|
reject: (reason?: unknown) => void
|
|
config: InternalAxiosRequestConfig
|
|
}
|
|
|
|
let isRefreshing = false
|
|
let queue: QueueItem[] = []
|
|
|
|
function processQueue(error: unknown, token: string | null) {
|
|
queue.forEach(({ resolve, reject, config }) => {
|
|
if (error || !token) {
|
|
reject(error)
|
|
} else {
|
|
config.headers.Authorization = `Bearer ${token}`
|
|
resolve(api(config))
|
|
}
|
|
})
|
|
queue = []
|
|
}
|
|
|
|
function redirectLogin() {
|
|
localStorage.removeItem(TOKEN_KEY)
|
|
localStorage.removeItem(REFRESH_KEY)
|
|
localStorage.removeItem(USER_KEY)
|
|
if (!window.location.pathname.startsWith('/login')) {
|
|
window.location.href = '/login'
|
|
}
|
|
}
|
|
|
|
api.interceptors.response.use(
|
|
response => response,
|
|
async (error: AxiosError) => {
|
|
const original = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
|
|
|
if (error.response?.status !== 401 || !original || original._retry) {
|
|
return Promise.reject(error)
|
|
}
|
|
|
|
// Login/refresh endpoint 401 → không retry (tránh infinite loop)
|
|
if (original.url?.includes('/auth/login') || original.url?.includes('/auth/refresh')) {
|
|
redirectLogin()
|
|
return Promise.reject(error)
|
|
}
|
|
|
|
original._retry = true
|
|
const refreshToken = localStorage.getItem(REFRESH_KEY)
|
|
if (!refreshToken) {
|
|
redirectLogin()
|
|
return Promise.reject(error)
|
|
}
|
|
|
|
if (isRefreshing) {
|
|
return new Promise((resolve, reject) => {
|
|
queue.push({ resolve, reject, config: original })
|
|
})
|
|
}
|
|
|
|
isRefreshing = true
|
|
try {
|
|
const res = await axios.post<{ accessToken: string; refreshToken: string }>(
|
|
'/api/auth/refresh',
|
|
{ refreshToken },
|
|
)
|
|
const newToken = res.data.accessToken
|
|
localStorage.setItem(TOKEN_KEY, newToken)
|
|
localStorage.setItem(REFRESH_KEY, res.data.refreshToken)
|
|
processQueue(null, newToken)
|
|
original.headers.Authorization = `Bearer ${newToken}`
|
|
return api(original)
|
|
} catch (refreshErr) {
|
|
processQueue(refreshErr, null)
|
|
redirectLogin()
|
|
return Promise.reject(refreshErr)
|
|
} finally {
|
|
isRefreshing = false
|
|
}
|
|
},
|
|
)
|