274 lines
7.9 KiB
JavaScript
274 lines
7.9 KiB
JavaScript
const DIRECTUS_URL = 'https://api.kashilo.com'
|
|
|
|
let _persist = false
|
|
|
|
export function setPersist(value) {
|
|
_persist = value
|
|
}
|
|
|
|
export function getPersist() {
|
|
return _persist
|
|
}
|
|
|
|
function _storage() {
|
|
return _persist ? localStorage : sessionStorage
|
|
}
|
|
|
|
class DirectusError extends Error {
|
|
constructor(status, message, data = {}) {
|
|
super(message)
|
|
this.name = 'DirectusError'
|
|
this.status = status
|
|
this.data = data
|
|
}
|
|
|
|
isAuthError() {
|
|
return this.status === 401 || this.status === 403
|
|
}
|
|
|
|
isNotFound() {
|
|
return this.status === 404
|
|
}
|
|
|
|
isValidationError() {
|
|
return this.status === 400
|
|
}
|
|
}
|
|
|
|
class DirectusClient {
|
|
constructor() {
|
|
this.baseUrl = DIRECTUS_URL
|
|
this.accessToken = null
|
|
this.refreshToken = null
|
|
this.tokenExpiry = null
|
|
this.refreshTimeout = null
|
|
this._refreshPromise = null
|
|
|
|
this.loadTokens()
|
|
this.setupVisibilityRefresh()
|
|
}
|
|
|
|
// ── Token Management ──
|
|
|
|
loadTokens() {
|
|
const stored = sessionStorage.getItem('kashilo_auth') || localStorage.getItem('kashilo_auth')
|
|
if (stored) {
|
|
try {
|
|
const { accessToken, refreshToken, expiry } = JSON.parse(stored)
|
|
this.accessToken = accessToken
|
|
this.refreshToken = refreshToken
|
|
this.tokenExpiry = expiry
|
|
if (localStorage.getItem('kashilo_auth')) {
|
|
_persist = true
|
|
}
|
|
this.scheduleTokenRefresh()
|
|
} catch (e) {
|
|
this.clearTokens()
|
|
}
|
|
}
|
|
}
|
|
|
|
saveTokens(accessToken, refreshToken, expiresIn) {
|
|
this.accessToken = accessToken
|
|
this.refreshToken = refreshToken
|
|
this.tokenExpiry = Date.now() + (expiresIn * 1000)
|
|
|
|
_storage().setItem('kashilo_auth', JSON.stringify({
|
|
accessToken: this.accessToken,
|
|
refreshToken: this.refreshToken,
|
|
expiry: this.tokenExpiry
|
|
}))
|
|
|
|
this.scheduleTokenRefresh()
|
|
}
|
|
|
|
clearTokens() {
|
|
this.accessToken = null
|
|
this.refreshToken = null
|
|
this.tokenExpiry = null
|
|
sessionStorage.removeItem('kashilo_auth')
|
|
localStorage.removeItem('kashilo_auth')
|
|
|
|
if (this.refreshTimeout) {
|
|
clearTimeout(this.refreshTimeout)
|
|
this.refreshTimeout = null
|
|
}
|
|
}
|
|
|
|
scheduleTokenRefresh() {
|
|
if (this.refreshTimeout) {
|
|
clearTimeout(this.refreshTimeout)
|
|
}
|
|
|
|
if (!this.tokenExpiry || !this.refreshToken) return
|
|
|
|
const refreshIn = this.tokenExpiry - Date.now() - 60000
|
|
|
|
if (refreshIn > 0) {
|
|
this.refreshTimeout = setTimeout(() => this.refreshSession(), refreshIn)
|
|
} else if (this.refreshToken) {
|
|
this.refreshSession()
|
|
}
|
|
}
|
|
|
|
setupVisibilityRefresh() {
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.visibilityState === 'visible' && this.refreshToken) {
|
|
const timeLeft = this.tokenExpiry - Date.now()
|
|
if (timeLeft < 120000) {
|
|
this.refreshSession()
|
|
}
|
|
this.scheduleTokenRefresh()
|
|
}
|
|
})
|
|
}
|
|
|
|
isAuthenticated() {
|
|
return !!this.accessToken && (!this.tokenExpiry || Date.now() < this.tokenExpiry)
|
|
}
|
|
|
|
async refreshSession(_retryCount = 0) {
|
|
if (!this.refreshToken) return false
|
|
|
|
try {
|
|
const response = await fetch(`${this.baseUrl}/auth/refresh`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
refresh_token: this.refreshToken,
|
|
mode: 'json'
|
|
})
|
|
})
|
|
|
|
if (!response.ok) throw new Error('Refresh failed')
|
|
|
|
const result = await response.json()
|
|
|
|
if (result.data) {
|
|
this.saveTokens(
|
|
result.data.access_token,
|
|
result.data.refresh_token,
|
|
result.data.expires
|
|
)
|
|
return true
|
|
}
|
|
} catch (e) {
|
|
if (_retryCount < 2) {
|
|
await new Promise(r => setTimeout(r, 2000))
|
|
return this.refreshSession(_retryCount + 1)
|
|
}
|
|
this.clearTokens()
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// ── HTTP Methods ──
|
|
|
|
async request(endpoint, options = {}, _retryCount = 0) {
|
|
const url = `${this.baseUrl}${endpoint}`
|
|
|
|
const headers = {
|
|
'Content-Type': 'application/json',
|
|
...options.headers
|
|
}
|
|
|
|
if (this.accessToken) {
|
|
headers['Authorization'] = `Bearer ${this.accessToken}`
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
...options,
|
|
headers
|
|
})
|
|
|
|
if (response.status === 401 && this.refreshToken && !endpoint.startsWith('/auth/') && _retryCount < 1) {
|
|
if (!this._refreshPromise) {
|
|
this._refreshPromise = this.refreshSession().finally(() => {
|
|
this._refreshPromise = null
|
|
})
|
|
}
|
|
const refreshed = await this._refreshPromise
|
|
if (!refreshed) {
|
|
this.clearTokens()
|
|
}
|
|
return this.request(endpoint, options, _retryCount + 1)
|
|
}
|
|
|
|
if (response.status === 429 && _retryCount < 3) {
|
|
const retryAfterHeader = response.headers.get('Retry-After') || '1'
|
|
let waitMs
|
|
if (parseInt(retryAfterHeader, 10) > 100) {
|
|
waitMs = parseInt(retryAfterHeader, 10)
|
|
} else {
|
|
waitMs = parseInt(retryAfterHeader, 10) * 1000
|
|
}
|
|
await new Promise(r => setTimeout(r, waitMs))
|
|
return this.request(endpoint, options, _retryCount + 1)
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}))
|
|
throw new DirectusError(response.status, error.errors?.[0]?.message || 'Request failed', error)
|
|
}
|
|
|
|
if (response.status === 204) {
|
|
return null
|
|
}
|
|
|
|
return await response.json()
|
|
} catch (error) {
|
|
if (error instanceof DirectusError) throw error
|
|
throw new DirectusError(0, 'Network error', { originalError: error })
|
|
}
|
|
}
|
|
|
|
async get(endpoint, params = {}) {
|
|
const queryString = this.buildQueryString(params)
|
|
const url = queryString ? `${endpoint}?${queryString}` : endpoint
|
|
return this.request(url, { method: 'GET' })
|
|
}
|
|
|
|
async post(endpoint, data) {
|
|
return this.request(endpoint, {
|
|
method: 'POST',
|
|
body: JSON.stringify(data)
|
|
})
|
|
}
|
|
|
|
async patch(endpoint, data) {
|
|
return this.request(endpoint, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data)
|
|
})
|
|
}
|
|
|
|
async delete(endpoint) {
|
|
return this.request(endpoint, { method: 'DELETE' })
|
|
}
|
|
|
|
buildQueryString(params) {
|
|
const searchParams = new URLSearchParams()
|
|
|
|
for (const [key, value] of Object.entries(params)) {
|
|
if (value === undefined || value === null) continue
|
|
|
|
if (key === 'sort' && Array.isArray(value)) {
|
|
searchParams.set(key, value.join(','))
|
|
} else if (key === 'fields' && Array.isArray(value)) {
|
|
searchParams.set(key, value.join(','))
|
|
} else if (typeof value === 'object') {
|
|
searchParams.set(key, JSON.stringify(value))
|
|
} else {
|
|
searchParams.set(key, value)
|
|
}
|
|
}
|
|
|
|
return searchParams.toString()
|
|
}
|
|
}
|
|
|
|
export const client = new DirectusClient()
|
|
export { DirectusError }
|