feat(frontend): unify authenticated requests with 401 session handling

- add utils/session.ts: on 401 clear auth state and redirect to SSO or /login with return path; singleton promise prevents duplicate redirects; typed AuthenticationRequiredError for callers
- add api/authRequest.ts: shared generic authRequest with token injection, response parsing and 401 hook
- migrate businessLine/containerService/delivery/machine/subsystemAuth/taskLog api modules to the shared helper, removing six duplicated implementations; keep getToken only for EventSource query-param auth
- Catalog: silence session-expiry error in instance-name precheck; prefer mysql delivery target by exact template name (matches backend DELIVERY_MYSQL_TEMPLATE_NAME default)
This commit is contained in:
Hungerdream
2026-07-30 18:14:09 +08:00
parent 67a10aa52d
commit 90434ee39b
9 changed files with 100 additions and 199 deletions
+38
View File
@@ -0,0 +1,38 @@
import { getToken } from '@/utils/auth'
import { handleUnauthorized } from '@/utils/session'
type JsonObject = Record<string, any>
export async function authRequest<T extends JsonObject = JsonObject>(path: string, init: RequestInit = {}): Promise<T> {
const token = getToken()
const response = await fetch(path, {
...init,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...init.headers,
},
})
const text = await response.text()
const data = parseResponseBody(text)
if (!response.ok) {
if (response.status === 401) {
return handleUnauthorized()
}
const message = data?.error || data?.message || text || `HTTP ${response.status}`
throw new Error(message)
}
return (data || {}) as T
}
function parseResponseBody(text: string): JsonObject {
if (!text.trim()) {
return {}
}
try {
return JSON.parse(text) as JsonObject
} catch {
return { error: text }
}
}
+13 -41
View File
@@ -1,4 +1,4 @@
import { getToken } from '@/utils/auth'
import { authRequest } from '@/api/authRequest'
export interface BusinessLine {
id: number
@@ -21,41 +21,31 @@ export interface SinaOrganization {
export const businessLineApi = {
async listMine(): Promise<BusinessLine[]> {
const token = getToken()
const response = await fetch('/auth/api/v1/business-lines', {
headers: {
Accept: 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(data.error || `HTTP ${response.status}`)
}
const data = await authRequest('/auth/api/v1/business-lines')
return Array.isArray(data.items) ? data.items : []
},
async listAll(): Promise<BusinessLine[]> {
const data = await request('/auth/api/v1/business-lines/all')
const data = await authRequest('/auth/api/v1/business-lines/all')
return Array.isArray(data.items) ? data.items : []
},
async create(name: string): Promise<BusinessLine> {
return request('/auth/api/v1/business-lines', {
return authRequest('/auth/api/v1/business-lines', {
method: 'POST',
body: JSON.stringify({ name }),
})
},
async update(id: number, name: string): Promise<BusinessLine> {
return request(`/auth/api/v1/business-lines/${id}`, {
return authRequest(`/auth/api/v1/business-lines/${id}`, {
method: 'PUT',
body: JSON.stringify({ name }),
})
},
async remove(id: number): Promise<void> {
await request(`/auth/api/v1/business-lines/${id}`, {
await authRequest(`/auth/api/v1/business-lines/${id}`, {
method: 'DELETE',
})
},
@@ -65,14 +55,14 @@ export const businessLineApi = {
target_user_id: number
target_business_line_id: number
}): Promise<void> {
await request('/auth/api/v1/business-lines/authorizations', {
await authRequest('/auth/api/v1/business-lines/authorizations', {
method: 'POST',
body: JSON.stringify(payload),
})
},
async listWayneNamespaces(): Promise<WayneNamespace[]> {
const data = await request('/auth/api/v1/wayne/namespaces')
const data = await authRequest('/auth/api/v1/wayne/namespaces')
const items = Array.isArray(data.data?.list) ? data.data.list : []
return items.map((item: any) => ({
id: Number(item.id),
@@ -82,12 +72,12 @@ export const businessLineApi = {
},
async listMappedWayneNamespaces(businessLineId: number): Promise<WayneNamespace[]> {
const data = await request(`/auth/api/v1/business-lines/${businessLineId}/wayne-namespaces`)
const data = await authRequest(`/auth/api/v1/business-lines/${businessLineId}/wayne-namespaces`)
return Array.isArray(data.items) ? data.items : []
},
async replaceMappedWayneNamespaces(businessLineId: number, namespaces: WayneNamespace[]): Promise<void> {
await request(`/auth/api/v1/business-lines/${businessLineId}/wayne-namespaces`, {
await authRequest(`/auth/api/v1/business-lines/${businessLineId}/wayne-namespaces`, {
method: 'PUT',
body: JSON.stringify({ namespaces }),
})
@@ -99,37 +89,19 @@ export const businessLineApi = {
params.set('keyword', keyword.trim())
}
const suffix = params.toString() ? `?${params.toString()}` : ''
const data = await request(`/auth/api/v1/business-lines/${businessLineId}/sina-organizations${suffix}`)
const data = await authRequest(`/auth/api/v1/business-lines/${businessLineId}/sina-organizations${suffix}`)
return Array.isArray(data.items) ? data.items : []
},
async listMappedSinaOrganizations(businessLineId: number): Promise<SinaOrganization[]> {
const data = await request(`/auth/api/v1/business-lines/${businessLineId}/sina-organization-mappings`)
const data = await authRequest(`/auth/api/v1/business-lines/${businessLineId}/sina-organization-mappings`)
return Array.isArray(data.items) ? data.items : []
},
async replaceMappedSinaOrganizations(businessLineId: number, organizations: SinaOrganization[]): Promise<void> {
await request(`/auth/api/v1/business-lines/${businessLineId}/sina-organization-mappings`, {
await authRequest(`/auth/api/v1/business-lines/${businessLineId}/sina-organization-mappings`, {
method: 'PUT',
body: JSON.stringify({ organizations }),
})
},
}
async function request(path: string, init: RequestInit = {}) {
const token = getToken()
const response = await fetch(path, {
...init,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...init.headers,
},
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(data.error || `HTTP ${response.status}`)
}
return data
}
+1 -32
View File
@@ -1,4 +1,4 @@
import { getToken } from '@/utils/auth'
import { authRequest } from '@/api/authRequest'
export interface ContainerServiceSummary {
businessLineId: number
@@ -102,34 +102,3 @@ export const containerServiceApi = {
}
},
}
async function authRequest(path: string, init: RequestInit = {}) {
const token = getToken()
const response = await fetch(path, {
...init,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...init.headers,
},
})
const text = await response.text()
const data = parseResponseBody(text)
if (!response.ok) {
const message = data?.error || data?.message || text || `HTTP ${response.status}`
throw new Error(message)
}
return data || {}
}
function parseResponseBody(text: string) {
if (!text.trim()) {
return {}
}
try {
return JSON.parse(text)
} catch {
return { error: text }
}
}
+1 -31
View File
@@ -1,4 +1,5 @@
import { getToken } from '@/utils/auth'
import { authRequest } from '@/api/authRequest'
export interface DeliveryTarget {
id: number
@@ -213,34 +214,3 @@ function createIdempotencyKey(payload: CreateMySQLDeliveryPayload) {
Date.now(),
].join(':')
}
async function authRequest(path: string, init: RequestInit = {}) {
const token = getToken()
const response = await fetch(path, {
...init,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...init.headers,
},
})
const text = await response.text()
const data = parseResponseBody(text)
if (!response.ok) {
const message = data?.error || data?.message || text || `HTTP ${response.status}`
throw new Error(message)
}
return data || {}
}
function parseResponseBody(text: string) {
if (!text.trim()) {
return {}
}
try {
return JSON.parse(text)
} catch {
return { error: text }
}
}
+1 -32
View File
@@ -1,4 +1,4 @@
import { getToken } from '@/utils/auth'
import { authRequest } from '@/api/authRequest'
export interface MachineOverview {
total: number
@@ -114,34 +114,3 @@ export const machineApi = {
await authRequest('/auth/api/v1/machines/sync', { method: 'POST' })
},
}
async function authRequest(path: string, init: RequestInit = {}) {
const token = getToken()
const response = await fetch(path, {
...init,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...init.headers,
},
})
const text = await response.text()
const data = parseResponseBody(text)
if (!response.ok) {
const message = data?.error || data?.message || text || `HTTP ${response.status}`
throw new Error(message)
}
return data || {}
}
function parseResponseBody(text: string) {
if (!text.trim()) {
return {}
}
try {
return JSON.parse(text)
} catch {
return { error: text }
}
}
+1 -32
View File
@@ -1,4 +1,4 @@
import { getToken } from '@/utils/auth'
import { authRequest } from '@/api/authRequest'
export interface SubsystemAuthSystem {
key: string
@@ -110,34 +110,3 @@ export const subsystemAuthApi = {
})
},
}
async function authRequest(path: string, init: RequestInit = {}) {
const token = getToken()
const response = await fetch(path, {
...init,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...init.headers,
},
})
const text = await response.text()
const data = parseResponseBody(text)
if (!response.ok) {
const message = data?.error || data?.message || text || `HTTP ${response.status}`
throw new Error(message)
}
return data || {}
}
function parseResponseBody(text: string) {
if (!text.trim()) {
return {}
}
try {
return JSON.parse(text)
} catch {
return { error: text }
}
}
+1 -31
View File
@@ -1,4 +1,5 @@
import { getToken } from '@/utils/auth'
import { authRequest } from '@/api/authRequest'
export interface TaskLogSummary {
id: string
@@ -71,37 +72,6 @@ export const taskLogApi = {
},
}
async function authRequest(path: string, init: RequestInit = {}) {
const token = getToken()
const response = await fetch(path, {
...init,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...init.headers,
},
})
const text = await response.text()
const data = parseResponseBody(text)
if (!response.ok) {
const message = data?.error || data?.message || text || `HTTP ${response.status}`
throw new Error(message)
}
return data || {}
}
function parseResponseBody(text: string) {
if (!text.trim()) {
return {}
}
try {
return JSON.parse(text)
} catch {
return { error: text }
}
}
export interface TaskLogStreamCallbacks {
onInit?: (lines: TaskLogLine[]) => void
onUpdate?: (lines: TaskLogLine[]) => void
+42
View File
@@ -0,0 +1,42 @@
import { authApi } from '@/api/auth'
import { useAuthStore } from '@/stores/auth'
import { redirectToSSO } from '@/utils/sso'
let unauthorizedRedirect: Promise<void> | null = null
export class AuthenticationRequiredError extends Error {
constructor() {
super('登录状态已失效,正在重新认证')
this.name = 'AuthenticationRequiredError'
}
}
export function isAuthenticationRequiredError(error: unknown): error is AuthenticationRequiredError {
return error instanceof AuthenticationRequiredError
}
export async function handleUnauthorized(): Promise<never> {
if (!unauthorizedRedirect) {
unauthorizedRedirect = redirectAfterClearingSession()
}
await unauthorizedRedirect
throw new AuthenticationRequiredError()
}
async function redirectAfterClearingSession(): Promise<void> {
useAuthStore().clearAuth()
try {
const { data } = await authApi.getConfig()
if (data.sso_enabled) {
redirectToSSO()
return
}
} catch {
redirectToSSO()
return
}
const redirect = `${window.location.pathname}${window.location.search}${window.location.hash}`
window.location.assign(`/login?redirect=${encodeURIComponent(redirect)}`)
}
+2
View File
@@ -522,6 +522,7 @@ import { deliveryApi, type DeliveryMountPath, type DeliveryTarget, type Deployme
import { useAuthStore } from '@/stores/auth'
import { useBusinessLineStore } from '@/stores/businessLine'
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
import { isAuthenticationRequiredError } from '@/utils/session'
type FlowView = 'config' | 'execution' | 'result'
type StepState = 'pending' | 'running' | 'done' | 'failed'
@@ -1295,6 +1296,7 @@ async function precheck() {
}
} catch (error) {
precheckPassed.value = false
if (isAuthenticationRequiredError(error)) return
ElMessage.error(error instanceof Error ? `实例名称可用性检查失败:${error.message}` : '实例名称可用性检查失败')
return
}