134 lines
5.5 KiB
JavaScript
134 lines
5.5 KiB
JavaScript
import { auditQuery } from './audit-logs.mjs'
|
||
export function normalizeUsername(value) { return value.trim().toLowerCase() }
|
||
export function validPassword(value) {
|
||
const bytes = new TextEncoder().encode(value).length
|
||
return bytes >= 6 && bytes <= 72
|
||
}
|
||
export function validUsername(value) { return /^[a-z][a-z0-9_.-]{2,31}$/.test(normalizeUsername(value)) }
|
||
export function createSession({ fetch, storage, changed = () => {} }) {
|
||
const key = 'lexgo-admin-token'
|
||
const state = { token: storage.getItem(key) || '', user: null, accounts: [], generation: 0 }
|
||
function clear() {
|
||
state.generation++
|
||
state.token = ''
|
||
state.user = null
|
||
state.accounts = []
|
||
storage.removeItem(key)
|
||
changed(state)
|
||
}
|
||
function assertCurrent(generation) {
|
||
if (generation !== state.generation) throw new Error('会话已变化,请重新操作')
|
||
}
|
||
async function request(path, method = 'GET', body, token = state.token, generation = state.generation) {
|
||
const multipart = typeof FormData !== 'undefined' && body instanceof FormData
|
||
const result = await fetch('/api/v1' + path, {
|
||
method,
|
||
headers: { ...(!multipart ? { 'Content-Type': 'application/json' } : {}), ...(token ? { Authorization: 'Bearer ' + token } : {}) },
|
||
...(body === undefined ? {} : { body: multipart ? body : JSON.stringify(body) }),
|
||
cache: 'no-store'
|
||
})
|
||
const payload = await result.json()
|
||
assertCurrent(generation)
|
||
if (!result.ok) {
|
||
if (result.status === 401 || result.status === 403) clear()
|
||
throw new Error(result.status === 401 ? '登录已失效,请重新登录' : payload.msg || '请求失败')
|
||
}
|
||
return payload.data
|
||
}
|
||
async function revoke(token) {
|
||
// Cleanup must never restore a previous identity or expose its response.
|
||
if (!token) return
|
||
let result
|
||
try {
|
||
result = await fetch('/api/v1/logout', { method: 'POST', headers: { Authorization: 'Bearer ' + token } })
|
||
} catch {
|
||
throw new Error('服务器尚未确认退出,原会话可能仍有效')
|
||
}
|
||
if (!result.ok && result.status !== 401) throw new Error('服务器尚未确认退出,原会话可能仍有效')
|
||
}
|
||
async function requireAdmin(user, token) {
|
||
if (user.role !== 'admin') {
|
||
clear()
|
||
try { await revoke(token) } catch (error) { throw new Error('此账号没有管理权限;' + error.message) }
|
||
throw new Error('此账号没有管理权限')
|
||
}
|
||
}
|
||
function authorized() { if (!state.user || state.user.role !== 'admin') throw new Error('请先登录管理员账号') }
|
||
return {
|
||
state, clear,
|
||
async login(username, password) {
|
||
const oldToken = state.token
|
||
clear()
|
||
const generation = state.generation
|
||
if (oldToken) await revoke(oldToken)
|
||
assertCurrent(generation)
|
||
const data = await request('/login', 'POST', { username: normalizeUsername(username), password }, '', generation)
|
||
assertCurrent(generation)
|
||
await requireAdmin(data.user, data.token)
|
||
assertCurrent(generation)
|
||
state.token = data.token
|
||
state.user = data.user
|
||
storage.setItem(key, data.token)
|
||
changed(state)
|
||
},
|
||
async restore() {
|
||
if (!state.token) return false
|
||
const generation = state.generation
|
||
try {
|
||
const user = await request('/me')
|
||
assertCurrent(generation)
|
||
await requireAdmin(user, state.token)
|
||
assertCurrent(generation)
|
||
state.user = user
|
||
changed(state)
|
||
return true
|
||
} catch (error) { if (generation === state.generation) clear(); throw error }
|
||
},
|
||
async logout() { const token = state.token; clear(); await revoke(token) },
|
||
async listDictionaries() {
|
||
authorized()
|
||
return request('/dictionaries')
|
||
},
|
||
async importDictionary(form) {
|
||
authorized()
|
||
if (!(form instanceof FormData)) throw new Error('请选择词典文件')
|
||
return request('/dictionaries/import', 'POST', form)
|
||
},
|
||
async setDictionaryEnabled(id, enabled) {
|
||
authorized()
|
||
if (!Number.isSafeInteger(id) || id <= 0 || typeof enabled !== 'boolean') throw new Error('词典参数无效')
|
||
return request('/dictionaries/' + id, 'PATCH', { enabled })
|
||
},
|
||
async queryAuditLogs(kind, filters) {
|
||
authorized()
|
||
const generation = state.generation
|
||
const query = auditQuery(kind, filters)
|
||
const data = await request('/' + kind + '-logs?' + query)
|
||
assertCurrent(generation)
|
||
return data
|
||
},
|
||
async loadAccounts() {
|
||
authorized()
|
||
const generation = state.generation
|
||
const data = await request('/accounts')
|
||
assertCurrent(generation)
|
||
state.accounts = data.items
|
||
changed(state)
|
||
},
|
||
async createAccount(username, password) {
|
||
authorized()
|
||
if (!validUsername(username)) throw new Error('账号须为 3–32 位,以字母开头,仅含字母、数字、点、下划线或短横线')
|
||
if (!validPassword(password)) throw new Error('密码须为 6–72 字节')
|
||
return request('/accounts', 'POST', { username: normalizeUsername(username), password })
|
||
},
|
||
async updateAccount(id, patch) {
|
||
authorized()
|
||
if (patch.password !== undefined && !validPassword(patch.password)) throw new Error('密码须为 6–72 字节')
|
||
if (id === state.user.id && patch.disabled) throw new Error('不能停用当前管理员')
|
||
const account = state.accounts.find(item => item.id === id)
|
||
if (id === state.user.id || (account && account.role !== 'learner')) throw new Error('管理员账号不支持此操作')
|
||
return request('/accounts/' + id, 'PATCH', patch)
|
||
}
|
||
}
|
||
}
|