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>
333 lines
11 KiB
JavaScript
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();
|
|
})();
|