fix(auth): редирект на portal по runtime /api/auth/config при 401
Docker / build (push) Failing after 20s
Docker / build (push) Failing after 20s
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -8,7 +8,7 @@ import type {
|
||||
Payment,
|
||||
BalanceLedgerRow,
|
||||
} from '@/types/entities'
|
||||
import { clearToken, getToken, isAuthEnabled, redirectToPortalLogin } from '@/lib/auth'
|
||||
import { clearToken, ensureAuthConfig, getToken, isAuthEnabled, redirectToPortalLogin } from '@/lib/auth'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? ''
|
||||
|
||||
@@ -27,20 +27,22 @@ async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T>
|
||||
if (options.body != null && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
if (isAuthEnabled()) {
|
||||
const token = getToken()
|
||||
if (token && !headers.has('Authorization')) {
|
||||
headers.set('Authorization', `Bearer ${token}`)
|
||||
}
|
||||
// Always attach token if present (API may require it even without VITE_AUTH_ENABLED)
|
||||
const token = getToken()
|
||||
if (token && !headers.has('Authorization')) {
|
||||
headers.set('Authorization', `Bearer ${token}`)
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
})
|
||||
if (!res.ok) {
|
||||
if (isAuthEnabled() && res.status === 401) {
|
||||
if (res.status === 401) {
|
||||
clearToken()
|
||||
redirectToPortalLogin()
|
||||
const cfg = await ensureAuthConfig()
|
||||
if (cfg.required || isAuthEnabled()) {
|
||||
redirectToPortalLogin(`${window.location.origin}/auth/callback`)
|
||||
}
|
||||
}
|
||||
let message = res.statusText || 'API error'
|
||||
try {
|
||||
@@ -153,23 +155,37 @@ export const api = {
|
||||
|
||||
downloadBackupJson: async (): Promise<Blob> => {
|
||||
const headers = new Headers()
|
||||
if (isAuthEnabled()) {
|
||||
const token = getToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
}
|
||||
const token = getToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
const res = await fetch(`${API_BASE}/api/backup/json`, { headers })
|
||||
if (!res.ok) throw new ApiError(res.statusText || 'Ошибка выгрузки', res.status)
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
clearToken()
|
||||
const cfg = await ensureAuthConfig()
|
||||
if (cfg.required || isAuthEnabled()) {
|
||||
redirectToPortalLogin(`${window.location.origin}/auth/callback`)
|
||||
}
|
||||
}
|
||||
throw new ApiError(res.statusText || 'Ошибка выгрузки', res.status)
|
||||
}
|
||||
return res.blob()
|
||||
},
|
||||
|
||||
downloadBackupDatabase: async (): Promise<Blob> => {
|
||||
const headers = new Headers()
|
||||
if (isAuthEnabled()) {
|
||||
const token = getToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
}
|
||||
const token = getToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
const res = await fetch(`${API_BASE}/api/backup/database`, { headers })
|
||||
if (!res.ok) throw new ApiError(res.statusText || 'Ошибка выгрузки', res.status)
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
clearToken()
|
||||
const cfg = await ensureAuthConfig()
|
||||
if (cfg.required || isAuthEnabled()) {
|
||||
redirectToPortalLogin(`${window.location.origin}/auth/callback`)
|
||||
}
|
||||
}
|
||||
throw new ApiError(res.statusText || 'Ошибка выгрузки', res.status)
|
||||
}
|
||||
return res.blob()
|
||||
},
|
||||
|
||||
@@ -178,16 +194,23 @@ export const api = {
|
||||
|
||||
importBackupDatabase: async (buffer: ArrayBuffer) => {
|
||||
const headers = new Headers({ 'Content-Type': 'application/octet-stream' })
|
||||
if (isAuthEnabled()) {
|
||||
const token = getToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
}
|
||||
const token = getToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
const res = await fetch(`${API_BASE}/api/backup/database`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: buffer,
|
||||
})
|
||||
if (!res.ok) throw new ApiError(res.statusText || 'Ошибка восстановления', res.status)
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
clearToken()
|
||||
const cfg = await ensureAuthConfig()
|
||||
if (cfg.required || isAuthEnabled()) {
|
||||
redirectToPortalLogin(`${window.location.origin}/auth/callback`)
|
||||
}
|
||||
}
|
||||
throw new ApiError(res.statusText || 'Ошибка восстановления', res.status)
|
||||
}
|
||||
return res.json()
|
||||
},
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Portal JWT storage + claims helpers for VPS Tracker UI. */
|
||||
|
||||
const TOKEN_KEY = 'vps_auth_token'
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? ''
|
||||
|
||||
export type AccessClaims = {
|
||||
sub: string
|
||||
@@ -13,6 +14,66 @@ export type AccessClaims = {
|
||||
exp?: number
|
||||
}
|
||||
|
||||
export type RuntimeAuthConfig = {
|
||||
required: boolean
|
||||
portalUrl: string
|
||||
}
|
||||
|
||||
let runtimeConfig: RuntimeAuthConfig | null = null
|
||||
let runtimeConfigPromise: Promise<RuntimeAuthConfig> | null = null
|
||||
|
||||
function viteAuthEnabled(): boolean {
|
||||
return (
|
||||
import.meta.env.VITE_AUTH_ENABLED === 'true' ||
|
||||
import.meta.env.VITE_AUTH_ENABLED === '1'
|
||||
)
|
||||
}
|
||||
|
||||
function vitePortalUrl(): string {
|
||||
return (import.meta.env.VITE_AUTH_PORTAL_URL ?? 'http://localhost:5175').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
)
|
||||
}
|
||||
|
||||
/** Load auth mode from API (Docker-friendly). Falls back to VITE_* flags. */
|
||||
export async function ensureAuthConfig(): Promise<RuntimeAuthConfig> {
|
||||
if (runtimeConfig) return runtimeConfig
|
||||
if (runtimeConfigPromise) return runtimeConfigPromise
|
||||
|
||||
runtimeConfigPromise = (async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/auth/config`)
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as {
|
||||
required?: boolean
|
||||
portal_url?: string
|
||||
}
|
||||
runtimeConfig = {
|
||||
required: Boolean(data.required) || viteAuthEnabled(),
|
||||
portalUrl: (data.portal_url || vitePortalUrl()).replace(/\/$/, ''),
|
||||
}
|
||||
return runtimeConfig
|
||||
}
|
||||
} catch {
|
||||
/* ignore — use vite defaults */
|
||||
}
|
||||
runtimeConfig = {
|
||||
required: viteAuthEnabled(),
|
||||
portalUrl: vitePortalUrl(),
|
||||
}
|
||||
return runtimeConfig
|
||||
})().finally(() => {
|
||||
runtimeConfigPromise = null
|
||||
})
|
||||
|
||||
return runtimeConfigPromise
|
||||
}
|
||||
|
||||
export function getAuthConfigSync(): RuntimeAuthConfig | null {
|
||||
return runtimeConfig
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
@@ -26,17 +87,13 @@ export function clearToken() {
|
||||
}
|
||||
|
||||
export function isAuthEnabled(): boolean {
|
||||
return (
|
||||
import.meta.env.VITE_AUTH_ENABLED === 'true' ||
|
||||
import.meta.env.VITE_AUTH_ENABLED === '1'
|
||||
)
|
||||
if (runtimeConfig) return runtimeConfig.required
|
||||
return viteAuthEnabled()
|
||||
}
|
||||
|
||||
export function authPortalUrl(): string {
|
||||
return (import.meta.env.VITE_AUTH_PORTAL_URL ?? 'http://localhost:5175').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
)
|
||||
if (runtimeConfig?.portalUrl) return runtimeConfig.portalUrl
|
||||
return vitePortalUrl()
|
||||
}
|
||||
|
||||
export function redirectToPortalLogin(returnTo?: string) {
|
||||
@@ -44,7 +101,7 @@ export function redirectToPortalLogin(returnTo?: string) {
|
||||
returnTo ?? `${window.location.origin}/auth/callback`
|
||||
const url = new URL(authPortalUrl())
|
||||
url.searchParams.set('return_to', callback)
|
||||
window.location.href = url.toString()
|
||||
window.location.assign(url.toString())
|
||||
}
|
||||
|
||||
export function parseHashToken(hash: string): {
|
||||
|
||||
Reference in New Issue
Block a user