mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
Merge PR #80: feat(perm) fine-grained account permissions
Conflict resolution + cross-PR integration: - player.ts: kept #88's POST /:botId/fm route AND gated it with requirePermission('player.control') so the new control endpoint honors #80's permission model (it was added without gating). - bot.ts: kept #81's relocated /settings routes (the relocation fixes the GET /settings shadow bug) and dropped #80's now-duplicate bottom copy; gated POST /settings with requirePermission('bot.manage'). - Navbar.vue: composed #82's dedicated-link scope with #80's permission filter — displayedBots is now the INTERSECTION (scope ∩ controllable allow-list). - database.ts: kept BOTH new table sets (#87 favorite_playlists + #80 user_permissions/user_bot_access). - bot.test.ts: updated to createRequireAuth(sessions, permissions) for #80's new two-arg signature. #80 review fixes (credential exposure / IDOR, adversarially verified): - GET /:id/config now requires bot.manage + bot access AND redacts ts6ApiKey + identity from the response (was readable by any authenticated member). - GET /:id and GET /:id/avatar now require bot access (were ungated read oracles).
This commit is contained in:
commit
bea2f92508
32 files changed
+2225
-137
No files matched your search
@@ -22,8 +22,9 @@
|
||||
<button class="scope-exit-btn" @click="exitScope" title="退出专属模式">退出</button>
|
||||
</div>
|
||||
|
||||
<!-- Normal: full selector with switching (shown when at least one bot exists) -->
|
||||
<div v-else-if="store.bots.length > 0" class="bot-selector" ref="selectorRef">
|
||||
<!-- Normal: full selector with switching (shown when at least one
|
||||
controllable bot exists — scope ∩ permission via displayedBots) -->
|
||||
<div v-else-if="displayedBots.length > 0" class="bot-selector" ref="selectorRef">
|
||||
<button class="bot-selector-btn" @click="dropdownOpen = !dropdownOpen">
|
||||
<span class="bot-dot" :class="{ online: activeBot?.connected }" />
|
||||
<span class="bot-selector-name">{{ activeBot?.name ?? '选择机器人' }}</span>
|
||||
@@ -143,17 +144,26 @@ import { useSession } from '../composables/useSession.js';
|
||||
|
||||
const store = usePlayerStore();
|
||||
const session = useSession();
|
||||
const { canControlBot } = session;
|
||||
const navRouter = useRouter();
|
||||
|
||||
async function onLogout() {
|
||||
await session.logout();
|
||||
navRouter.replace({ name: 'login' });
|
||||
}
|
||||
// Belt-and-suspenders: the backend already scopes store.bots to the allowed
|
||||
// set for members, but filtering here keeps the UI correct if an admin (who
|
||||
// sees all bots) is constrained, or if the list ever isn't pre-filtered.
|
||||
const controllableBots = computed(() => store.bots.filter((b) => canControlBot(b.id)));
|
||||
const activeBot = computed(() => store.activeBot);
|
||||
// While scoped (dedicated link), the selector is locked to the single scoped
|
||||
// bot; otherwise the full list is shown and switching is allowed.
|
||||
// The bots shown in the selector are the INTERSECTION of the permission
|
||||
// allow-list (controllableBots) and the dedicated-link scope: while scoped the
|
||||
// selector is locked to the single scoped bot, otherwise the full controllable
|
||||
// list is shown and switching is allowed.
|
||||
const displayedBots = computed(() =>
|
||||
store.isScoped ? store.bots.filter((b) => b.id === store.scopedBotId) : store.bots,
|
||||
store.isScoped
|
||||
? controllableBots.value.filter((b) => b.id === store.scopedBotId)
|
||||
: controllableBots.value,
|
||||
);
|
||||
const dropdownOpen = ref(false);
|
||||
const selectorRef = ref<HTMLElement | null>(null);
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
<Queue :open="showQueue" @close="showQueue = false" />
|
||||
|
||||
<div class="player-bar frosted-glass">
|
||||
<!-- Progress bar -->
|
||||
<!-- Progress bar (read-only display; seek interaction gated on player.control) -->
|
||||
<div
|
||||
class="progress-bar-container"
|
||||
:class="{ 'no-seek': !canControl }"
|
||||
ref="progressBarRef"
|
||||
@click="onProgressClick"
|
||||
@mousemove="onProgressHover"
|
||||
@@ -37,32 +38,38 @@
|
||||
|
||||
<div class="player-center">
|
||||
<span class="time-display time-current">{{ formatTime(currentElapsed) }}</span>
|
||||
<button class="control-btn" @click="store.prev()">
|
||||
<Icon icon="mdi:skip-previous" />
|
||||
</button>
|
||||
<button class="play-btn" @click="togglePlay">
|
||||
<Icon :icon="store.isPlaying ? 'mdi:pause' : 'mdi:play'" />
|
||||
</button>
|
||||
<button class="control-btn" @click="store.next()">
|
||||
<Icon icon="mdi:skip-next" />
|
||||
</button>
|
||||
<button class="control-btn mode-btn" @click="cycleMode" :title="modeLabel">
|
||||
<Icon :icon="modeIcon" />
|
||||
<span class="mode-label">{{ modeLabel }}</span>
|
||||
</button>
|
||||
<!-- Transport controls require player.control -->
|
||||
<template v-if="canControl">
|
||||
<button class="control-btn" @click="store.prev()">
|
||||
<Icon icon="mdi:skip-previous" />
|
||||
</button>
|
||||
<button class="play-btn" @click="togglePlay">
|
||||
<Icon :icon="store.isPlaying ? 'mdi:pause' : 'mdi:play'" />
|
||||
</button>
|
||||
<button class="control-btn" @click="store.next()">
|
||||
<Icon icon="mdi:skip-next" />
|
||||
</button>
|
||||
<button class="control-btn mode-btn" @click="cycleMode" :title="modeLabel">
|
||||
<Icon :icon="modeIcon" />
|
||||
<span class="mode-label">{{ modeLabel }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<span class="time-display time-total">{{ formatTime(currentSong?.duration ?? 0) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="player-right">
|
||||
<Icon icon="mdi:volume-high" class="volume-icon" />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
:value="activeBot?.volume ?? 75"
|
||||
@change="onVolumeChange"
|
||||
class="volume-slider"
|
||||
/>
|
||||
<!-- Volume requires player.control -->
|
||||
<template v-if="canControl">
|
||||
<Icon icon="mdi:volume-high" class="volume-icon" />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
:value="activeBot?.volume ?? 75"
|
||||
@change="onVolumeChange"
|
||||
class="volume-slider"
|
||||
/>
|
||||
</template>
|
||||
<button class="control-btn" :class="{ active: showQueue }" @click="showQueue = !showQueue">
|
||||
<Icon icon="mdi:playlist-music" />
|
||||
</button>
|
||||
@@ -79,6 +86,7 @@ import { computed, ref, onMounted, onUnmounted } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { usePlayerStore } from '../stores/player.js';
|
||||
import { useSession } from '../composables/useSession.js';
|
||||
import CoverArt from './CoverArt.vue';
|
||||
import Queue from './Queue.vue';
|
||||
|
||||
@@ -86,6 +94,9 @@ const route = useRoute();
|
||||
const router = useRouter();
|
||||
const showQueue = ref(false);
|
||||
|
||||
const { can } = useSession();
|
||||
const canControl = computed(() => can('player.control'));
|
||||
|
||||
const store = usePlayerStore();
|
||||
const activeBot = computed(() => store.activeBot);
|
||||
const currentSong = computed(() => store.currentSong);
|
||||
@@ -133,6 +144,7 @@ function updateProgress() {
|
||||
}
|
||||
|
||||
async function onProgressClick(e: MouseEvent) {
|
||||
if (!canControl.value) return; // seek requires player.control
|
||||
const bar = progressBarRef.value;
|
||||
if (!bar) return;
|
||||
const rect = bar.getBoundingClientRect();
|
||||
@@ -237,6 +249,14 @@ function cycleMode() {
|
||||
.progress-bar-bg { height: 4px; }
|
||||
.progress-bar-thumb { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
&.no-seek {
|
||||
cursor: default;
|
||||
&:hover {
|
||||
.progress-bar-bg { height: 2px; }
|
||||
.progress-bar-thumb { opacity: 0; transform: scale(0); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bar-bg {
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
<div class="queue-header">
|
||||
<h3 class="queue-title">播放队列</h3>
|
||||
<span class="queue-count">{{ botQueue.length }} 首</span>
|
||||
<button
|
||||
v-if="botQueue.length > 0"
|
||||
class="clear-btn"
|
||||
@click="clearAndStop"
|
||||
<button
|
||||
v-if="botQueue.length > 0 && can('player.control')"
|
||||
class="clear-btn"
|
||||
@click="clearAndStop"
|
||||
title="清空队列并停止播放"
|
||||
>
|
||||
<Icon icon="mdi:stop-circle-outline" />
|
||||
@@ -33,7 +33,7 @@
|
||||
<div class="queue-song-name">{{ song.name }}</div>
|
||||
<div class="queue-song-artist">{{ song.artist }}</div>
|
||||
</div>
|
||||
<button class="remove-btn" @click="removeSong(i)" title="移除">
|
||||
<button v-if="can('player.queue')" class="remove-btn" @click="removeSong(i)" title="移除">
|
||||
<Icon icon="mdi:close" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -46,6 +46,7 @@ import { watch, computed } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import axios from 'axios';
|
||||
import { usePlayerStore } from '../stores/player.js';
|
||||
import { useSession } from '../composables/useSession.js';
|
||||
import CoverArt from './CoverArt.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -57,6 +58,7 @@ defineEmits<{
|
||||
}>();
|
||||
|
||||
const store = usePlayerStore();
|
||||
const { can } = useSession();
|
||||
const botQueue = computed(() => store.queue);
|
||||
|
||||
// Fetch queue when panel opens
|
||||
@@ -65,6 +67,7 @@ watch(() => props.open, (isOpen) => {
|
||||
});
|
||||
|
||||
async function playAtIndex(index: number) {
|
||||
if (!can('player.control')) return;
|
||||
await store.playAtIndex(index);
|
||||
await store.fetchQueue();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
role: 'admin' | 'member';
|
||||
capabilities?: string[];
|
||||
bots?: "all" | string[];
|
||||
}
|
||||
|
||||
const currentUser = ref<User | null>(null);
|
||||
@@ -70,6 +72,8 @@ async function login(username: string, password: string): Promise<void> {
|
||||
throw new Error(body.error ?? `login failed (${res.status})`);
|
||||
}
|
||||
currentUser.value = (await res.json()) as User;
|
||||
// Login response omits capabilities/bots; fetch the authoritative ones from /me.
|
||||
await refreshMe();
|
||||
}
|
||||
|
||||
async function setup(username: string, password: string): Promise<void> {
|
||||
@@ -85,6 +89,8 @@ async function setup(username: string, password: string): Promise<void> {
|
||||
}
|
||||
currentUser.value = (await res.json()) as User;
|
||||
needsSetup.value = false;
|
||||
// Setup response omits capabilities/bots; fetch the authoritative ones from /me.
|
||||
await refreshMe();
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
@@ -93,6 +99,18 @@ async function logout(): Promise<void> {
|
||||
currentUser.value = null;
|
||||
}
|
||||
|
||||
function can(cap: string): boolean {
|
||||
const u = currentUser.value;
|
||||
return !!u && (u.role === "admin" || (u.capabilities ?? []).includes(cap));
|
||||
}
|
||||
|
||||
function canControlBot(botId: string): boolean {
|
||||
const u = currentUser.value;
|
||||
if (!u) return false;
|
||||
if (u.role === "admin" || u.bots === "all") return true;
|
||||
return Array.isArray(u.bots) && u.bots.includes(botId);
|
||||
}
|
||||
|
||||
export function useSession() {
|
||||
return {
|
||||
currentUser: readonly(currentUser),
|
||||
@@ -104,5 +122,7 @@ export function useSession() {
|
||||
login,
|
||||
logout,
|
||||
setup,
|
||||
can,
|
||||
canControlBot,
|
||||
};
|
||||
}
|
||||
+205
-38
@@ -50,8 +50,8 @@
|
||||
<p v-if="ownPwSuccess" class="user-success">{{ ownPwSuccess }}</p>
|
||||
</section>
|
||||
|
||||
<!-- Bot Management -->
|
||||
<section class="settings-section">
|
||||
<!-- Bot Management (create/edit/delete/start-stop) requires bot.manage -->
|
||||
<section v-if="can('bot.manage')" class="settings-section">
|
||||
<h2 class="section-title">机器人管理</h2>
|
||||
<div class="bot-list">
|
||||
<div v-for="bot in store.bots" :key="bot.id" class="bot-item">
|
||||
@@ -157,8 +157,8 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Music Account - QR Code Login -->
|
||||
<section class="settings-section">
|
||||
<!-- Music Account - QR Code Login (platform auth) requires platform.auth -->
|
||||
<section v-if="can('platform.auth')" class="settings-section">
|
||||
<h2 class="section-title">音乐账号</h2>
|
||||
|
||||
<!-- NetEase -->
|
||||
@@ -371,8 +371,8 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Audio Quality -->
|
||||
<section class="settings-section">
|
||||
<!-- Audio Quality requires quality -->
|
||||
<section v-if="can('quality')" class="settings-section">
|
||||
<h2 class="section-title">音质设置</h2>
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
@@ -410,7 +410,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Idle Timeout -->
|
||||
<section class="settings-section">
|
||||
<section v-if="can('bot.manage')" class="settings-section">
|
||||
<h2 class="section-title">行为设置</h2>
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
@@ -448,7 +448,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Bot Profile (TeamSpeak Behavior) -->
|
||||
<section class="settings-section">
|
||||
<section v-if="can('bot.manage')" class="settings-section">
|
||||
<h2 class="section-title">机器人 Profile(TeamSpeak 行为)</h2>
|
||||
<p class="profile-section-hint">控制 bot 在 TeamSpeak 上自动同步歌曲信息的方式。⚠️ 标记的项会触发频道里所有人的提示音。</p>
|
||||
<div v-if="store.bots.length === 0" class="empty-hint">还没有机器人,先在上面创建一个。</div>
|
||||
@@ -504,38 +504,96 @@
|
||||
<section v-if="session.isAdmin.value" class="settings-section">
|
||||
<h2 class="section-title">用户管理</h2>
|
||||
<div class="user-list">
|
||||
<div v-for="u in userList" :key="u.id" class="user-item">
|
||||
<div class="user-info">
|
||||
<div class="user-name">
|
||||
{{ u.username }}
|
||||
<span class="user-role-badge" :class="`role-${u.role}`">
|
||||
{{ u.role === 'admin' ? '管理员' : '成员' }}
|
||||
</span>
|
||||
<span v-if="session.currentUser.value && u.id === session.currentUser.value.id" class="user-self-badge">本人</span>
|
||||
<div v-for="u in userList" :key="u.id" class="user-row-wrap">
|
||||
<div class="user-item">
|
||||
<div class="user-info">
|
||||
<div class="user-name">
|
||||
{{ u.username }}
|
||||
<span class="user-role-badge" :class="`role-${u.role}`">
|
||||
{{ u.role === 'admin' ? '管理员' : '成员' }}
|
||||
</span>
|
||||
<span v-if="session.currentUser.value && u.id === session.currentUser.value.id" class="user-self-badge">本人</span>
|
||||
</div>
|
||||
<div class="user-created">创建于 {{ formatDate(u.createdAt) }}</div>
|
||||
</div>
|
||||
<div class="user-actions">
|
||||
<span v-if="u.role === 'admin'" class="perm-admin-label">全部权限(管理员)</span>
|
||||
<button
|
||||
v-else
|
||||
class="btn-sm"
|
||||
:class="{ 'btn-primary': permEditingId === u.id }"
|
||||
@click="onTogglePermEditor(u)"
|
||||
>
|
||||
<Icon icon="mdi:shield-key" /> 权限
|
||||
</button>
|
||||
<button class="btn-sm" @click="openResetPassword(u)">
|
||||
<Icon icon="mdi:lock-reset" /> 重置密码
|
||||
</button>
|
||||
<button
|
||||
class="btn-sm"
|
||||
:disabled="changingRoleId === u.id || isLastAdmin(u)"
|
||||
:title="isLastAdmin(u) ? '不能降级唯一的管理员' : (u.role === 'admin' ? '降级为成员' : '提升为管理员')"
|
||||
@click="onToggleRole(u)"
|
||||
>
|
||||
<Icon icon="mdi:account-cog" />
|
||||
{{ u.role === 'admin' ? '降为成员' : '提升管理员' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn-sm btn-delete"
|
||||
:disabled="!!(session.currentUser.value && u.id === session.currentUser.value.id) || isLastAdmin(u)"
|
||||
:title="session.currentUser.value && u.id === session.currentUser.value.id ? '不能删除自己' : (isLastAdmin(u) ? '不能删除唯一的管理员' : '')"
|
||||
@click="onDeleteUser(u)"
|
||||
>
|
||||
<Icon icon="mdi:delete" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="user-created">创建于 {{ formatDate(u.createdAt) }}</div>
|
||||
</div>
|
||||
<div class="user-actions">
|
||||
<button class="btn-sm" @click="openResetPassword(u)">
|
||||
<Icon icon="mdi:lock-reset" /> 重置密码
|
||||
</button>
|
||||
<button
|
||||
class="btn-sm"
|
||||
:disabled="changingRoleId === u.id || isLastAdmin(u)"
|
||||
:title="isLastAdmin(u) ? '不能降级唯一的管理员' : (u.role === 'admin' ? '降级为成员' : '提升为管理员')"
|
||||
@click="onToggleRole(u)"
|
||||
>
|
||||
<Icon icon="mdi:account-cog" />
|
||||
{{ u.role === 'admin' ? '降为成员' : '提升管理员' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn-sm btn-delete"
|
||||
:disabled="!!(session.currentUser.value && u.id === session.currentUser.value.id) || isLastAdmin(u)"
|
||||
:title="session.currentUser.value && u.id === session.currentUser.value.id ? '不能删除自己' : (isLastAdmin(u) ? '不能删除唯一的管理员' : '')"
|
||||
@click="onDeleteUser(u)"
|
||||
>
|
||||
<Icon icon="mdi:delete" />
|
||||
</button>
|
||||
|
||||
<!-- Inline permission editor (members only) -->
|
||||
<div v-if="permEditingId === u.id" class="perm-editor">
|
||||
<div v-if="permLoading" class="user-empty">加载权限中…</div>
|
||||
<template v-else>
|
||||
<div class="perm-group">
|
||||
<div class="perm-group-title">能力</div>
|
||||
<div class="perm-checks">
|
||||
<label v-for="cap in CAPABILITIES" :key="cap.token" class="perm-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="permDraft.capabilities.includes(cap.token)"
|
||||
@change="toggleCapability(cap.token, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
{{ cap.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="perm-group">
|
||||
<div class="perm-group-title">机器人</div>
|
||||
<label class="perm-check">
|
||||
<input type="checkbox" v-model="permDraft.botsAll" />
|
||||
全部机器人
|
||||
</label>
|
||||
<div v-if="!permDraft.botsAll" class="perm-checks perm-bots">
|
||||
<label v-for="bot in store.bots" :key="bot.id" class="perm-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="permDraft.selectedBotIds.includes(bot.id)"
|
||||
@change="toggleBotSelection(bot.id, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
{{ bot.name }}
|
||||
</label>
|
||||
<span v-if="store.bots.length === 0" class="user-empty">还没有机器人。</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="permError" class="user-error">{{ permError }}</p>
|
||||
<div class="form-actions">
|
||||
<button class="btn-sm" @click="permEditingId = null">取消</button>
|
||||
<button class="btn-sm btn-primary" :disabled="permSaving" @click="onSavePermissions(u)">
|
||||
{{ permSaving ? '保存中…' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="userList.length === 0 && !userLoadError" class="user-empty">加载中…</div>
|
||||
@@ -987,6 +1045,7 @@ async function updateProfile(botId: string, key: keyof ProfileConfig, value: boo
|
||||
|
||||
// --- User Management ---
|
||||
const session = useSession();
|
||||
const { can } = session;
|
||||
|
||||
// --- Own password change (available to all authenticated users) ---
|
||||
const ownPw = reactive({ old: '', new: '', confirm: '' });
|
||||
@@ -1152,6 +1211,91 @@ async function onConfirmReset() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Per-user permission editor (members only) ---
|
||||
const CAPABILITIES: { token: string; label: string }[] = [
|
||||
{ token: 'player.control', label: '播放控制' },
|
||||
{ token: 'player.queue', label: '队列管理' },
|
||||
{ token: 'bot.manage', label: '机器人管理' },
|
||||
{ token: 'platform.auth', label: '平台登录凭据' },
|
||||
{ token: 'quality', label: '音质设置' },
|
||||
];
|
||||
|
||||
const permEditingId = ref<string | null>(null);
|
||||
const permLoading = ref(false);
|
||||
const permSaving = ref(false);
|
||||
const permError = ref('');
|
||||
const permDraft = reactive<{ capabilities: string[]; botsAll: boolean; selectedBotIds: string[] }>({
|
||||
capabilities: [],
|
||||
botsAll: true,
|
||||
selectedBotIds: [],
|
||||
});
|
||||
|
||||
async function onTogglePermEditor(u: UserListEntry) {
|
||||
if (permEditingId.value === u.id) {
|
||||
permEditingId.value = null;
|
||||
return;
|
||||
}
|
||||
permEditingId.value = u.id;
|
||||
permError.value = '';
|
||||
permLoading.value = true;
|
||||
permDraft.capabilities = [];
|
||||
permDraft.botsAll = true;
|
||||
permDraft.selectedBotIds = [];
|
||||
try {
|
||||
const res = await fetch(`/api/users/${u.id}/permissions`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = await res.json();
|
||||
permDraft.capabilities = Array.isArray(body.capabilities) ? [...body.capabilities] : [];
|
||||
if (body.bots === 'all') {
|
||||
permDraft.botsAll = true;
|
||||
permDraft.selectedBotIds = [];
|
||||
} else {
|
||||
permDraft.botsAll = false;
|
||||
permDraft.selectedBotIds = Array.isArray(body.bots) ? [...body.bots] : [];
|
||||
}
|
||||
} catch (e) {
|
||||
permError.value = (e as Error).message;
|
||||
} finally {
|
||||
permLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCapability(token: string, checked: boolean) {
|
||||
const has = permDraft.capabilities.includes(token);
|
||||
if (checked && !has) permDraft.capabilities.push(token);
|
||||
else if (!checked && has) permDraft.capabilities = permDraft.capabilities.filter((t) => t !== token);
|
||||
}
|
||||
|
||||
function toggleBotSelection(id: string, checked: boolean) {
|
||||
const has = permDraft.selectedBotIds.includes(id);
|
||||
if (checked && !has) permDraft.selectedBotIds.push(id);
|
||||
else if (!checked && has) permDraft.selectedBotIds = permDraft.selectedBotIds.filter((b) => b !== id);
|
||||
}
|
||||
|
||||
async function onSavePermissions(u: UserListEntry) {
|
||||
permSaving.value = true;
|
||||
permError.value = '';
|
||||
try {
|
||||
const res = await fetch(`/api/users/${u.id}/permissions`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
capabilities: [...permDraft.capabilities],
|
||||
bots: permDraft.botsAll ? 'all' : [...permDraft.selectedBotIds],
|
||||
}),
|
||||
});
|
||||
if (!res.ok && res.status !== 204) {
|
||||
const b = await res.json().catch(() => ({}));
|
||||
throw new Error(b.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
permEditingId.value = null;
|
||||
} catch (e) {
|
||||
permError.value = (e as Error).message;
|
||||
} finally {
|
||||
permSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
@@ -1202,6 +1346,7 @@ function describeAction(e: AuditEntry): string {
|
||||
case 'user.password_reset': return `重置 ${target} 的密码`;
|
||||
case 'user.password_changed': return `修改自己的密码`;
|
||||
case 'user.role_changed': return `变更 ${target} 的角色`;
|
||||
case 'user.permissions_changed': return `权限变更 → ${target}`;
|
||||
default: return `${e.action} → ${target}`;
|
||||
}
|
||||
}
|
||||
@@ -1930,6 +2075,28 @@ onUnmounted(() => {
|
||||
.role-member { background: rgba(150, 150, 150, 0.18); color: var(--text-secondary); }
|
||||
.user-role-select { flex: 0 0 110px; }
|
||||
|
||||
.user-row-wrap { display: flex; flex-direction: column; gap: 0; }
|
||||
.perm-admin-label { font-size: 12px; color: var(--text-secondary); align-self: center; }
|
||||
.perm-editor {
|
||||
margin-top: -2px;
|
||||
padding: 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.perm-group { display: flex; flex-direction: column; gap: 8px; }
|
||||
.perm-group-title { font-size: 13px; font-weight: 500; color: var(--text-primary); }
|
||||
.perm-checks { display: flex; flex-wrap: wrap; gap: 8px 16px; }
|
||||
.perm-bots { padding-left: 16px; }
|
||||
.perm-check {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 13px; color: var(--text-secondary); cursor: pointer;
|
||||
}
|
||||
.perm-check input { cursor: pointer; }
|
||||
|
||||
// --- Account section (own password change) ---
|
||||
.account-info-card {
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
|
||||
Reference in new issue
Block a user