Files
lexgo/admin/src/session.mjs
T

134 lines
5.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}