feat(admin): let admins restrict which public groups a user may access

Public groups have always been bindable by every user: CanBindGroup returned
true for any non-exclusive group, and user_allowed_groups only ever carried the
exclusive groups an admin had granted. Admins had no way to hand a single user
a subset of the public groups short of converting a group to exclusive, which
changes it for everyone already using it.

A user now carries restrict_public_groups. While it is false, which is the
default and what every existing row migrates to, nothing changes: every public
group stays bindable. Once an admin turns it on for a user, that user's public
groups are narrowed to the ones listed in user_allowed_groups, the same table
that already gates exclusive groups.

The flag is an administrative control, so it rides on the admin user DTO only
and leaves the shape of the end-user endpoints alone.

The model plaza filter honours the flag as well, so a restricted user is not
shown groups they would be refused when binding a key. Anonymous visitors have
no user record and keep the previous view.

Enforcement rides on the existing CanBindGroup choke point, so it covers key
creation, key updates, and per-request authorization together. An API key bound
to a group that is later withdrawn stops working at request time rather than
lingering as a key that can be listed but not used.

The admin dialog gains a toggle over the public group list. Turning it off
re-checks every public group, so an admin cannot save a list that reads as
restrictive while the restriction itself is disabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
akihitohyh
2026-08-28 02:40:17 +08:00
co-authored by Claude Opus 5
parent efb46db0a9
commit b56c61eccd
29 changed files with 507 additions and 93 deletions
@@ -95,11 +95,23 @@
<!-- 公开分组区域 -->
<div v-if="publicGroups.length > 0">
<div class="mb-3 flex items-center gap-2">
<div class="mb-3 flex flex-wrap items-center gap-2">
<div class="h-1.5 w-1.5 rounded-full bg-green-500"></div>
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{ t('admin.users.publicGroups') }}</h4>
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300">
{{ restrictPublicGroups ? t('admin.users.publicGroupsRestricted') : t('admin.users.publicGroups') }}
</h4>
<span class="text-xs text-gray-400">({{ publicGroupConfigs.length }})</span>
<label class="ml-auto flex cursor-pointer items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<input
type="checkbox"
:checked="restrictPublicGroups"
@change="toggleRestrictPublicGroups"
class="h-4 w-4 cursor-pointer rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-500"
/>
{{ t('admin.users.restrictPublicGroups') }}
</label>
</div>
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.users.restrictPublicGroupsHint') }}</p>
<div class="grid gap-3">
<div
v-for="config in publicGroupConfigs"
@@ -107,9 +119,19 @@
class="relative overflow-hidden rounded-xl border-2 border-green-200 bg-green-50/50 p-4 dark:border-green-800/50 dark:bg-green-900/10"
>
<div class="flex items-center gap-4">
<!-- 复选框(禁用状态) -->
<!-- 未开启限制时公开分组恒可用,此处仅作展示;开启后才是真实开关 -->
<div class="flex-shrink-0">
<div class="flex h-5 w-5 items-center justify-center rounded-md border-2 border-green-400 bg-green-500 dark:border-green-600 dark:bg-green-600">
<input
v-if="restrictPublicGroups"
type="checkbox"
:checked="config.isSelected"
@change="togglePublicGroup(config.groupId)"
class="h-5 w-5 cursor-pointer rounded-md border-2 border-green-400 text-green-600 focus:ring-green-500 dark:border-green-600"
/>
<div
v-else
class="flex h-5 w-5 items-center justify-center rounded-md border-2 border-green-400 bg-green-500 dark:border-green-600 dark:bg-green-600"
>
<svg class="h-full w-full text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
@@ -207,6 +229,7 @@ const groupConfigs = ref<GroupRateConfig[]>([])
const originalGroupRates = ref<Record<number, number>>({}) // 记录原始专属倍率,用于检测删除
const loading = ref(false)
const submitting = ref(false)
const restrictPublicGroups = ref(false)
// 分离专属分组和公开分组
const exclusiveGroups = computed(() => groups.value.filter((g) => g.is_exclusive))
@@ -234,6 +257,7 @@ const load = async () => {
// 初始化配置
const userAllowedGroups = props.user?.allowed_groups || []
const userGroupRates = props.user?.group_rates || {}
restrictPublicGroups.value = props.user?.restrict_public_groups ?? false
// 保存原始专属倍率,用于检测删除操作
originalGroupRates.value = { ...userGroupRates }
@@ -246,8 +270,9 @@ const load = async () => {
defaultRate: g.rate_multiplier,
customRate: userGroupRates[g.id] ?? null,
// 专属分组:检查是否在 allowed_groups 中
// 公开分组:始终选中
isSelected: g.is_exclusive ? userAllowedGroups.includes(g.id) : true,
// 公开分组:未开启限制时恒可用;开启后同样以 allowed_groups 为准
isSelected:
g.is_exclusive || restrictPublicGroups.value ? userAllowedGroups.includes(g.id) : true,
}))
} catch (error) {
console.error('Failed to load groups:', error)
@@ -263,6 +288,23 @@ const toggleExclusiveGroup = (groupId: number) => {
}
}
const togglePublicGroup = (groupId: number) => {
const config = groupConfigs.value.find((c) => c.groupId === groupId)
if (config && !config.isExclusive) {
config.isSelected = !config.isSelected
}
}
// 关闭限制时把公开分组全部勾回,避免保存出一份"限制已关但只勾了两个"的误导状态。
const toggleRestrictPublicGroups = () => {
restrictPublicGroups.value = !restrictPublicGroups.value
if (!restrictPublicGroups.value) {
for (const config of groupConfigs.value) {
if (!config.isExclusive) config.isSelected = true
}
}
}
const updateCustomRate = (groupId: number, value: string) => {
const config = groupConfigs.value.find((c) => c.groupId === groupId)
if (config) {
@@ -280,8 +322,11 @@ const handleSave = async () => {
submitting.value = true
try {
// 构建 allowed_groups(仅包含专属分组中被勾选的)
const allowedGroups = groupConfigs.value.filter((c) => c.isExclusive && c.isSelected).map((c) => c.groupId)
// 构建 allowed_groups:专属分组中被勾选的,以及开启限制后被勾选的公开分组。
// 未开启限制时不写入公开分组,保持该表"额外授予"的原有语义。
const allowedGroups = groupConfigs.value
.filter((c) => c.isSelected && (c.isExclusive || restrictPublicGroups.value))
.map((c) => c.groupId)
// 构建 group_rates
// - 有新专属倍率: 设置为该值
@@ -301,6 +346,7 @@ const handleSave = async () => {
await adminAPI.users.update(props.user.id, {
allowed_groups: allowedGroups,
restrict_public_groups: restrictPublicGroups.value,
group_rates: Object.keys(groupRates).length > 0 ? groupRates : undefined,
})