Files
Echo-Tavern/static/js/admin.js
T
leeexinandClaude Opus 4.6 ae478755be feat(auth,admin): add LinuxDO OAuth login and admin settings
Implement LinuxDO OAuth authorization-code login flow with state validation and local account binding, and add admin APIs/UI to manage OAuth runtime settings with URL validation and secret masking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 13:14:46 +08:00

333 lines
11 KiB
JavaScript

(function () {
const body = document.body;
const user = (() => {
try {
return JSON.parse(body.dataset.user || 'null');
} catch {
return null;
}
})();
const statusEl = document.getElementById('status');
const usersTable = document.getElementById('users-table');
const roomsTable = document.getElementById('rooms-table');
const cardsTable = document.getElementById('cards-table');
const oauthForm = document.getElementById('linuxdo-oauth-form');
const ROLE_OPTIONS = ['player', 'moderator', 'admin'];
const setStatus = (text) => {
statusEl.textContent = text || '';
};
const getJson = async (url) => {
const res = await fetch(url);
return res.json();
};
const patchJson = async (url, payload) => {
const res = await fetch(url, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
return res.json();
};
const deleteJson = async (url) => {
const res = await fetch(url, { method: 'DELETE' });
return res.json();
};
const formatDate = (isoText) => {
const raw = String(isoText || '');
if (!raw) return '-';
try {
return new Date(raw).toLocaleString();
} catch {
return raw;
}
};
const makeTable = (headers) => {
const table = document.createElement('table');
table.className = 'admin-table';
const thead = document.createElement('thead');
const tr = document.createElement('tr');
headers.forEach((h) => {
const th = document.createElement('th');
th.textContent = h;
tr.appendChild(th);
});
thead.appendChild(tr);
table.appendChild(thead);
const tbody = document.createElement('tbody');
table.appendChild(tbody);
return { table, tbody };
};
const renderUsers = (rows) => {
usersTable.textContent = '';
const { table, tbody } = makeTable(['用户名', '权限组', '创建时间', '操作']);
rows.forEach((row) => {
const tr = document.createElement('tr');
const tdUsername = document.createElement('td');
tdUsername.textContent = row.username || '-';
tr.appendChild(tdUsername);
const tdRole = document.createElement('td');
const select = document.createElement('select');
ROLE_OPTIONS.forEach((role) => {
const option = document.createElement('option');
option.value = role;
option.textContent = role;
if (role === row.role) option.selected = true;
select.appendChild(option);
});
tdRole.appendChild(select);
tr.appendChild(tdRole);
const tdCreated = document.createElement('td');
tdCreated.textContent = formatDate(row.created_at);
tr.appendChild(tdCreated);
const tdAction = document.createElement('td');
const saveBtn = document.createElement('button');
saveBtn.type = 'button';
saveBtn.className = 'secondary-btn';
saveBtn.textContent = '保存权限';
saveBtn.addEventListener('click', async () => {
setStatus(`正在更新 ${row.username} 的权限...`);
try {
const data = await patchJson(`/api/admin/users/${row.user_id}/role`, { role: select.value });
if (!data.ok) {
setStatus(`更新失败: ${data.error || '未知错误'}`);
return;
}
setStatus(`已更新 ${row.username} 权限为 ${select.value}`);
loadAll();
} catch (error) {
setStatus(`网络错误: ${error}`);
}
});
tdAction.appendChild(saveBtn);
tr.appendChild(tdAction);
tbody.appendChild(tr);
});
usersTable.appendChild(table);
};
const renderRooms = (rows) => {
roomsTable.textContent = '';
const { table, tbody } = makeTable(['房间码', '剧本卡', '状态', '玩家数', '最近更新', '操作']);
rows.forEach((row) => {
const tr = document.createElement('tr');
const tdRoom = document.createElement('td');
tdRoom.textContent = row.room_id || '-';
tr.appendChild(tdRoom);
const tdCard = document.createElement('td');
tdCard.textContent = row.card_title || row.game_card_id || '-';
tr.appendChild(tdCard);
const tdStatus = document.createElement('td');
tdStatus.textContent = row.status || '-';
tr.appendChild(tdStatus);
const tdCount = document.createElement('td');
tdCount.textContent = String(row.player_count ?? 0);
tr.appendChild(tdCount);
const tdUpdated = document.createElement('td');
tdUpdated.textContent = formatDate(row.updated_at);
tr.appendChild(tdUpdated);
const tdAction = document.createElement('td');
const delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.className = 'ghost-btn';
delBtn.textContent = '强制结束并删除';
delBtn.addEventListener('click', async () => {
if (!confirm(`确认删除房间 ${row.room_id} 吗?`)) return;
setStatus(`正在删除房间 ${row.room_id}...`);
try {
const data = await deleteJson(`/api/admin/rooms/${row.room_id}`);
if (!data.ok) {
setStatus(`删除失败: ${data.error || '未知错误'}`);
return;
}
setStatus(`房间 ${row.room_id} 已删除。`);
loadAll();
} catch (error) {
setStatus(`网络错误: ${error}`);
}
});
tdAction.appendChild(delBtn);
tr.appendChild(tdAction);
tbody.appendChild(tr);
});
roomsTable.appendChild(table);
};
const renderCards = (rows) => {
cardsTable.textContent = '';
const { table, tbody } = makeTable(['标题', '作者', '官方', '创建时间', '操作']);
rows.forEach((row) => {
const tr = document.createElement('tr');
const tdTitle = document.createElement('td');
tdTitle.textContent = row.title || '-';
tr.appendChild(tdTitle);
const tdCreator = document.createElement('td');
tdCreator.textContent = row.creator_display_name || '-';
tr.appendChild(tdCreator);
const tdOfficial = document.createElement('td');
tdOfficial.textContent = row.is_official ? '是' : '否';
tr.appendChild(tdOfficial);
const tdCreated = document.createElement('td');
tdCreated.textContent = formatDate(row.created_at);
tr.appendChild(tdCreated);
const tdAction = document.createElement('td');
const editBtn = document.createElement('a');
editBtn.className = 'link-btn';
editBtn.href = `/cards/${row.card_id}/edit`;
editBtn.textContent = '编辑';
tdAction.appendChild(editBtn);
const toggleBtn = document.createElement('button');
toggleBtn.type = 'button';
toggleBtn.className = 'secondary-btn';
toggleBtn.textContent = row.is_official ? '设为玩家卡' : '设为官方卡';
toggleBtn.style.marginTop = '0.4rem';
toggleBtn.addEventListener('click', async () => {
setStatus(`正在更新游戏卡「${row.title}」...`);
try {
const data = await patchJson(`/api/admin/cards/${row.card_id}`, {
is_official: !row.is_official,
});
if (!data.ok) {
setStatus(`更新失败: ${data.error || '未知错误'}`);
return;
}
setStatus(`游戏卡「${row.title}」已更新。`);
loadAll();
} catch (error) {
setStatus(`网络错误: ${error}`);
}
});
tdAction.appendChild(toggleBtn);
const delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.className = 'ghost-btn';
delBtn.style.marginTop = '0.4rem';
delBtn.textContent = '删除游戏卡';
delBtn.addEventListener('click', async () => {
if (!confirm(`确认删除游戏卡「${row.title}」吗?`)) return;
setStatus(`正在删除游戏卡「${row.title}」...`);
try {
const data = await deleteJson(`/api/admin/cards/${row.card_id}`);
if (!data.ok) {
setStatus(`删除失败: ${data.error || '未知错误'}`);
return;
}
setStatus(`游戏卡「${row.title}」已删除。`);
loadAll();
} catch (error) {
setStatus(`网络错误: ${error}`);
}
});
tdAction.appendChild(delBtn);
tr.appendChild(tdAction);
tbody.appendChild(tr);
});
cardsTable.appendChild(table);
};
const loadLinuxdoOauth = async () => {
if (!oauthForm) return true;
const data = await getJson('/api/admin/settings/oauth/linuxdo');
if (!data.ok) {
setStatus(data.error || '加载 OAuth 配置失败');
return false;
}
const cfg = data.config || {};
document.getElementById('linuxdo-enabled').value = cfg.enabled ? 'true' : 'false';
document.getElementById('linuxdo-client-id').value = cfg.client_id || '';
document.getElementById('linuxdo-client-secret').value = '';
document.getElementById('linuxdo-authorize-url').value = cfg.authorize_url || '';
document.getElementById('linuxdo-token-url').value = cfg.token_url || '';
document.getElementById('linuxdo-userinfo-url').value = cfg.userinfo_url || '';
document.getElementById('linuxdo-scope').value = cfg.scope || '';
return true;
};
if (oauthForm) {
oauthForm.addEventListener('submit', async (event) => {
event.preventDefault();
setStatus('正在保存 OAuth 配置...');
const payload = {
enabled: document.getElementById('linuxdo-enabled').value === 'true',
client_id: document.getElementById('linuxdo-client-id').value.trim(),
client_secret: document.getElementById('linuxdo-client-secret').value,
authorize_url: document.getElementById('linuxdo-authorize-url').value.trim(),
token_url: document.getElementById('linuxdo-token-url').value.trim(),
userinfo_url: document.getElementById('linuxdo-userinfo-url').value.trim(),
scope: document.getElementById('linuxdo-scope').value.trim(),
};
if (!payload.client_secret) {
delete payload.client_secret;
}
try {
const data = await patchJson('/api/admin/settings/oauth/linuxdo', payload);
if (!data.ok) {
setStatus(data.error || '保存失败');
return;
}
document.getElementById('linuxdo-client-secret').value = '';
setStatus('OAuth 配置已保存。');
} catch (error) {
setStatus(`网络错误: ${error}`);
}
});
}
const loadAll = async () => {
if (!user || user.role !== 'admin') {
window.location.href = '/';
return;
}
setStatus('正在加载后台数据...');
try {
const [usersResp, roomsResp, cardsResp, oauthOk] = await Promise.all([
getJson('/api/admin/users'),
getJson('/api/admin/rooms'),
getJson('/api/admin/cards'),
loadLinuxdoOauth(),
]);
if (!usersResp.ok || !roomsResp.ok || !cardsResp.ok || !oauthOk) {
const msg = usersResp.error || roomsResp.error || cardsResp.error || '加载失败';
setStatus(msg);
return;
}
renderUsers(usersResp.users || []);
renderRooms(roomsResp.rooms || []);
renderCards(cardsResp.cards || []);
setStatus('后台数据已更新。');
} catch (error) {
setStatus(`网络错误: ${error}`);
}
};
loadAll();
})();