feat: add role-based address limit configuration (#741)

- Add RoleAddressConfig component in admin panel
- Implement role_address_config API endpoints (GET/POST)
- Add getMaxAddressCount function with validation chain
- Priority: role config > global settings
- Support editable table with clearable input
- Add extensible RoleConfig type for future fields
- Use context for current user, query DB for target user
- Optimize state management (remove redundant roleConfigsMap)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Dream Hunter
2025-10-12 18:12:36 +08:00
committed by GitHub
parent 09a6cac8fe
commit 6109ab9e82
7 changed files with 209 additions and 8 deletions

View File

@@ -14,6 +14,7 @@ import AccountSettings from './admin/AccountSettings.vue';
import UserManagement from './admin/UserManagement.vue';
import UserSettings from './admin/UserSettings.vue';
import UserOauth2Settings from './admin/UserOauth2Settings.vue';
import RoleAddressConfig from './admin/RoleAddressConfig.vue';
import Mails from './admin/Mails.vue';
import MailsUnknow from './admin/MailsUnknow.vue';
import About from './common/About.vue';
@@ -61,6 +62,7 @@ const { t } = useI18n({
user_management: 'User Management',
user_settings: 'User Settings',
userOauth2Settings: 'Oauth2 Settings',
roleAddressConfig: 'Role Address Config',
unknow: 'Mails with unknow receiver',
senderAccess: 'Sender Access Control',
sendBox: 'Send Box',
@@ -88,6 +90,7 @@ const { t } = useI18n({
user_management: '用户管理',
user_settings: '用户设置',
userOauth2Settings: 'Oauth2 设置',
roleAddressConfig: '角色地址配置',
unknow: '无收件人邮件',
senderAccess: '发件权限控制',
sendBox: '发件箱',
@@ -173,6 +176,9 @@ onMounted(async () => {
<n-tab-pane name="userOauth2Settings" :tab="t('userOauth2Settings')">
<UserOauth2Settings />
</n-tab-pane>
<n-tab-pane name="roleAddressConfig" :tab="t('roleAddressConfig')">
<RoleAddressConfig />
</n-tab-pane>
</n-tabs>
</n-tab-pane>
<n-tab-pane name="mails" :tab="t('mails')">

View File

@@ -0,0 +1,153 @@
<script setup>
import { ref, onMounted, h } from 'vue';
import { useI18n } from 'vue-i18n'
import { NInputNumber, NTag, NSpace, NButton } from 'naive-ui';
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { loading } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
messages: {
en: {
role: 'Role',
maxAddressCount: 'Max Address Count',
save: 'Save',
successTip: 'Success',
noRolesAvailable: 'No roles available in system config',
roleConfigDesc: 'Configure maximum address count for each user role. Role-based limits take priority over global settings.',
notConfigured: 'Not Configured (Use Global Settings)',
},
zh: {
role: '角色',
maxAddressCount: '最大地址数量',
save: '保存',
successTip: '成功',
noRolesAvailable: '系统配置中没有可用的角色',
roleConfigDesc: '为每个用户角色配置最大地址数量。角色配置优先于全局设置。',
notConfigured: '未配置(使用全局设置)',
}
}
});
const systemRoles = ref([])
const tableData = ref([])
const fetchUserRoles = async () => {
try {
const results = await api.fetch(`/admin/user_roles`);
systemRoles.value = results;
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const fetchRoleConfigs = async () => {
try {
const { configs } = await api.fetch(`/admin/role_address_config`);
tableData.value = systemRoles.value.map(roleObj => ({
role: roleObj.role,
max_address_count: configs[roleObj.role]?.maxAddressCount ?? null,
}));
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const saveConfig = async () => {
try {
// convert tableData to object with nested structure
const configs = {};
tableData.value.forEach(row => {
if (row.max_address_count !== null && row.max_address_count !== undefined) {
configs[row.role] = { maxAddressCount: row.max_address_count };
}
});
await api.fetch(`/admin/role_address_config`, {
method: 'POST',
body: JSON.stringify({ configs })
});
message.success(t('successTip'));
await fetchRoleConfigs();
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const columns = [
{
title: t('role'),
key: 'role',
width: 200,
render(row) {
return h(NTag, {
type: 'info',
bordered: false
}, {
default: () => row.role
})
}
},
{
title: t('maxAddressCount'),
key: 'max_address_count',
render(row) {
return h(NInputNumber, {
value: row.max_address_count,
min: 0,
max: 999,
clearable: true,
placeholder: t('notConfigured'),
style: 'width: 200px;',
onUpdateValue: (value) => {
row.max_address_count = value;
}
})
}
}
]
onMounted(async () => {
await fetchUserRoles();
await fetchRoleConfigs();
})
</script>
<template>
<div style="margin-top: 10px;">
<n-alert type="info" :bordered="false" style="margin-bottom: 20px;">
{{ t('roleConfigDesc') }}
</n-alert>
<n-alert v-if="systemRoles.length === 0" type="warning" :bordered="false">
{{ t('noRolesAvailable') }}
</n-alert>
<div v-else>
<n-space justify="end" style="margin-bottom: 12px;">
<n-button :loading="loading" @click="saveConfig" type="primary">
{{ t('save') }}
</n-button>
</n-space>
<n-data-table
:columns="columns"
:data="tableData"
:bordered="false"
embedded
/>
</div>
</div>
</template>
<style scoped>
.n-data-table {
min-width: 600px;
}
</style>

View File

@@ -2,7 +2,7 @@ import { Context } from 'hono';
import { CONSTANTS } from '../constants';
import { getJsonSetting, saveSetting, checkUserPassword, getDomains, getUserRoles } from '../utils';
import { UserSettings, GeoData, UserInfo } from "../models";
import { UserSettings, GeoData, UserInfo, RoleAddressConfig } from "../models";
import { handleListQuery } from '../common'
import UserBindAddressModule from '../user_api/bind_address';
import i18n from '../i18n';
@@ -166,4 +166,14 @@ export default {
results: results,
});
},
getRoleAddressConfig: async (c: Context<HonoCustomType>) => {
const value = await getJsonSetting<RoleAddressConfig>(c, CONSTANTS.ROLE_ADDRESS_CONFIG_KEY);
const configs = value || {};
return c.json({ configs });
},
saveRoleAddressConfig: async (c: Context<HonoCustomType>) => {
const { configs } = await c.req.json<{ configs: RoleAddressConfig }>();
await saveSetting(c, CONSTANTS.ROLE_ADDRESS_CONFIG_KEY, JSON.stringify(configs));
return c.json({ success: true });
},
}

View File

@@ -344,6 +344,8 @@ api.post('/admin/users', admin_user_api.createUser)
api.post('/admin/users/:user_id/reset_password', admin_user_api.resetPassword)
api.get('/admin/user_roles', async (c) => c.json(getUserRoles(c)))
api.post('/admin/user_roles', admin_user_api.updateUserRoles)
api.get('/admin/role_address_config', admin_user_api.getRoleAddressConfig)
api.post('/admin/role_address_config', admin_user_api.saveRoleAddressConfig)
api.get('/admin/users/bind_address/:user_id', admin_user_api.getBindedAddresses)
api.post('/admin/users/bind_address', admin_user_api.bindAddress)

View File

@@ -14,6 +14,7 @@ export const CONSTANTS = {
VERIFIED_ADDRESS_LIST_KEY: 'verified_address_list',
NO_LIMIT_SEND_ADDRESS_LIST_KEY: 'no_limit_send_address_list',
EMAIL_RULE_SETTINGS_KEY: 'email_rule_settings',
ROLE_ADDRESS_CONFIG_KEY: 'role_address_config',
// KV
TG_KV_PREFIX: "temp-mail-telegram",

View File

@@ -154,3 +154,10 @@ export type EmailRuleSettings = {
blockReceiveUnknowAddressEmail: boolean;
emailForwardingList: SubdomainForwardAddressList[]
}
export type RoleConfig = {
maxAddressCount?: number;
// future configs can be added here
}
export type RoleAddressConfig = Record<string, RoleConfig>;

View File

@@ -1,12 +1,26 @@
import { Context } from 'hono';
import { Jwt } from 'hono/utils/jwt'
import { UserSettings } from "../models";
import { UserSettings, RoleAddressConfig } from "../models";
import { getJsonSetting } from "../utils"
import { CONSTANTS } from "../constants";
import { unbindTelegramByAddress } from '../telegram_api/common';
import i18n from '../i18n';
import { updateAddressUpdatedAt } from '../common';
import { updateAddressUpdatedAt, commonGetUserRole } from '../common';
const getMaxAddressCount = async (
c: Context<HonoCustomType>,
userRole: string | null | undefined,
settings: UserSettings
): Promise<number> => {
if (!userRole) return settings.maxAddressCount;
const roleConfigs = await getJsonSetting<RoleAddressConfig>(c, CONSTANTS.ROLE_ADDRESS_CONFIG_KEY);
if (!roleConfigs) return settings.maxAddressCount;
const roleMaxCount = roleConfigs[userRole]?.maxAddressCount;
if (typeof roleMaxCount !== 'number') return settings.maxAddressCount;
if (roleMaxCount <= 0) return settings.maxAddressCount;
return roleMaxCount;
};
const UserBindAddressModule = {
bind: async (c: Context<HonoCustomType>) => {
@@ -43,11 +57,15 @@ const UserBindAddressModule = {
// check if binded address count
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
const settings = new UserSettings(value);
if (settings.maxAddressCount > 0) {
// get user role
const userRole = c.get("userRolePayload");
// check role-based max address count first, fallback to global settings
const maxAddressCount = await getMaxAddressCount(c, userRole, settings);
if (maxAddressCount > 0) {
const { count } = await c.env.DB.prepare(
`SELECT COUNT(*) as count FROM users_address where user_id = ?`
).bind(user_id).first<{ count: number }>() || { count: 0 };
if (count >= settings.maxAddressCount) {
if (count >= maxAddressCount) {
return c.text("Max address count reached", 400)
}
}
@@ -194,18 +212,22 @@ const UserBindAddressModule = {
// check if target user exists
const target_user_id = await c.env.DB.prepare(
`SELECT id FROM users where user_email = ?`
).bind(target_user_email).first("id");
).bind(target_user_email).first<number>("id");
if (!target_user_id) {
return c.text("Target user not found", 400)
}
// check target user binded address count
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
const settings = new UserSettings(value);
if (settings.maxAddressCount > 0) {
// get target user role
const userRoleObj = await commonGetUserRole(c, target_user_id);
// check role-based max address count first, fallback to global settings
const maxAddressCount = await getMaxAddressCount(c, userRoleObj?.role, settings);
if (maxAddressCount > 0) {
const { count } = await c.env.DB.prepare(
`SELECT COUNT(*) as count FROM users_address where user_id = ?`
).bind(target_user_id).first<{ count: number }>() || { count: 0 };
if (count >= settings.maxAddressCount) {
if (count >= maxAddressCount) {
return c.text("Target User Max address count reached", 400)
}
}