dsh-im-ops/plugin-src/client/access-policy-settings.js
2026-09-01 10:46:41 +08:00

351 lines
14 KiB
JavaScript

import * as React from 'react';
import {
DEFAULT_ACCESS_POLICY,
normalizeAccessPolicy,
validateAccessPolicy,
} from '../../src/channels/shared/access-policy.mjs';
import { h, localizeText } from './i18n.js';
export const ACCESS_POLICY_ENDPOINT = 'bot.access-policy.set';
export const ACCESS_CHANNEL_DEFINITIONS = Object.freeze({
weixin: Object.freeze({
directUserLabel: '微信用户 ID',
directPlaceholder: '填写微信用户 ID',
groupSupported: false,
}),
feishu: Object.freeze({
directUserLabel: '飞书 Open ID',
directPlaceholder: 'ou_xxx',
groupUserLabel: '群成员 Open ID',
groupPlaceholder: 'ou_xxx',
}),
dingtalk: Object.freeze({
directUserLabel: '钉钉用户 ID',
directPlaceholder: '填写 senderStaffId 或 senderId',
groupUserLabel: '群成员用户 ID',
groupPlaceholder: '填写 senderStaffId 或 senderId',
}),
wecom: Object.freeze({
directUserLabel: '企业微信用户 ID',
directPlaceholder: '填写 userid',
groupUserLabel: '群成员用户 ID',
groupPlaceholder: '填写 userid',
}),
qq: Object.freeze({
directUserLabel: 'QQ User Open ID',
directPlaceholder: '填写 user_openid',
groupUserLabel: '群成员 Open ID',
groupPlaceholder: '填写 member_openid',
}),
slack: Object.freeze({
directUserLabel: 'Slack User ID',
directPlaceholder: 'U0123456789',
groupUserLabel: '群成员 User ID',
groupPlaceholder: 'U0123456789',
}),
telegram: Object.freeze({
directUserLabel: 'Telegram User ID',
directPlaceholder: '填写数字 User ID',
groupUserLabel: '群成员 User ID',
groupPlaceholder: '填写数字 User ID',
}),
discord: Object.freeze({
directUserLabel: 'Discord User ID',
directPlaceholder: '填写数字 User ID',
groupUserLabel: '群成员 User ID',
groupPlaceholder: '填写数字 User ID',
}),
whatsapp: Object.freeze({
directUserLabel: 'WhatsApp 电话号码或 JID',
directPlaceholder: '8613800000000 或完整 JID',
groupUserLabel: '群成员电话号码或 JID',
groupPlaceholder: '8613800000000 或完整 JID',
}),
});
function clonePolicy(policy) {
const cloneScope = (scope) => ({
mode: scope.mode,
open: {
defaultCanExecuteCommands: scope.open.defaultCanExecuteCommands,
commandPermissionOverrides: scope.open.commandPermissionOverrides.map((user) => ({
...user,
})),
},
allowlist: {
users: scope.allowlist.users.map((user) => ({ ...user })),
},
});
return {
direct: cloneScope(policy.direct),
group: cloneScope(policy.group),
};
}
function unwrapRpcResult(result) {
if (result?.ok === true) return result.value;
if (result?.ok === false) {
const error = new Error(result.error?.message || '访问设置保存失败,请稍后重试。');
error.code = result.error?.code;
throw error;
}
return result;
}
function policyFromSnapshot(value, botId) {
const source = value?.snapshot ?? value;
const bot = Array.isArray(source?.bots)
? source.bots.find((entry) => entry?.botId === botId)
: null;
return normalizeAccessPolicy(bot?.accessPolicy ?? source?.accessPolicy ?? source?.policy);
}
function commandValue(value) {
return value === 'allow';
}
function ScenePolicyEditor({
scene,
title,
policy,
userLabel,
placeholder,
disabled = false,
unsupported = false,
onChange,
}) {
const ownerHelpId = React.useId();
const emptyAllowlistHelpId = React.useId();
const allowlist = policy.mode === 'allowlist';
const collectionKey = allowlist ? 'users' : 'commandPermissionOverrides';
const branchKey = allowlist ? 'allowlist' : 'open';
const users = policy[branchKey][collectionKey];
const emptyAllowlist = allowlist && users.length === 0;
const updateUsers = (nextUsers) => onChange({
...policy,
[branchKey]: {
...policy[branchKey],
[collectionKey]: nextUsers,
},
});
const updateUser = (index, patch) => updateUsers(users.map((user, userIndex) => (
userIndex === index ? { ...user, ...patch } : user
)));
return h('fieldset', {
className: 'dim-accessScene',
disabled,
'data-scene': scene,
'aria-label': localizeText(title),
},
h('legend', null,
h('span', { className: 'dim-accessLegendContent' },
h('span', null, title),
h('span', { className: 'dim-channelHelp dim-accessLegendHelp' },
h('button', {
type: 'button',
className: 'dim-channelHelpButton',
'aria-label': [localizeText(title), localizeText('查看访问权限说明')].join(' '),
'aria-describedby': ownerHelpId,
}, h('span', { 'aria-hidden': true }, '?')),
h('span', {
id: ownerHelpId,
className: 'dim-channelTooltip dim-accessHelpTooltip',
role: 'tooltip',
}, '原所有者或扫码接入者始终可以访问并执行命令;以下设置仅约束其他用户。')))),
unsupported
? h('div', { className: 'dim-accessUnsupported', role: 'note' },
h('strong', null, '当前渠道不支持群聊'),
h('p', null, '此区域无需配置,保存私聊设置时会保留现有群聊策略。'))
: h(React.Fragment, null,
h('div', { className: 'dim-accessControls', 'data-mode': policy.mode },
h('label', { className: 'dim-accessField' },
h('span', null, '访问模式'),
h('select', {
value: policy.mode,
'aria-label': [localizeText(title), localizeText('访问模式')].join(' '),
onChange: (event) => onChange({ ...policy, mode: event.target.value }),
},
h('option', { value: 'open' }, '允许所有用户'),
h('option', { value: 'allowlist' }, '仅白名单用户'))),
allowlist ? null : h('label', { className: 'dim-accessField' },
h('span', null, '默认命令权限'),
h('select', {
value: policy.open.defaultCanExecuteCommands ? 'allow' : 'deny',
'aria-label': [localizeText(title), localizeText('默认命令权限')].join(' '),
onChange: (event) => onChange({
...policy,
open: {
...policy.open,
defaultCanExecuteCommands: commandValue(event.target.value),
},
}),
},
h('option', { value: 'allow' }, '可以执行命令'),
h('option', { value: 'deny' }, '不可以执行命令')))),
h('div', { className: 'dim-accessUsers' },
h('div', { className: 'dim-accessUsersHeading' },
h('div', { className: 'dim-accessUsersTitle' },
h('strong', null, allowlist ? '白名单用户' : '命令权限例外'),
emptyAllowlist
? h('span', { className: 'dim-channelHelp dim-accessUsersHelp' },
h('button', {
type: 'button',
className: 'dim-channelHelpButton',
'aria-label': [localizeText(title), localizeText('查看白名单说明')].join(' '),
'aria-describedby': emptyAllowlistHelpId,
}, h('span', { 'aria-hidden': true }, '?')),
h('span', {
id: emptyAllowlistHelpId,
className: 'dim-channelTooltip dim-accessEmptyAllowlistTooltip',
role: 'tooltip',
}, '当前没有白名单用户,保存后普通用户将无法使用机器人。'))
: null),
h('button', {
type: 'button',
className: 'dim-deliveryButton dim-accessAddUser',
'aria-label': [localizeText(title), localizeText('新增用户')].join(' '),
title: localizeText('新增用户'),
onClick: () => updateUsers([...users, {
id: '',
canExecuteCommands: allowlist
? false
: !policy.open.defaultCanExecuteCommands,
}]),
}, h('span', { 'aria-hidden': true }, '+'))),
users.length === 0
? h('div', { className: 'dim-accessUsersEmpty' }, '尚未添加用户')
: h('ul', { className: 'dim-accessUserList' }, users.map((user, index) =>
h('li', { key: `${scene}-${policy.mode}-${index}`, className: 'dim-accessUserRow' },
h('label', { className: 'dim-accessField dim-accessUserId' },
h('span', null, userLabel),
h('input', {
value: user.id,
maxLength: 256,
required: true,
autoCapitalize: 'none',
autoCorrect: 'off',
spellCheck: false,
placeholder,
'aria-label': [localizeText(title), localizeText(userLabel), index + 1].join(' '),
onChange: (event) => updateUser(index, { id: event.target.value }),
})),
h('label', { className: 'dim-accessField dim-accessUserCommand' },
h('span', null, '命令权限'),
h('select', {
value: user.canExecuteCommands ? 'allow' : 'deny',
'aria-label': [
localizeText(title), localizeText('用户'), index + 1,
localizeText('命令权限'),
].join(' '),
onChange: (event) => updateUser(index, {
canExecuteCommands: commandValue(event.target.value),
}),
},
h('option', { value: 'allow' }, '可以执行命令'),
h('option', { value: 'deny' }, '不可以执行命令'))),
h('button', {
type: 'button',
className: 'dim-deliveryButton dim-accessDeleteUser',
'data-kind': 'danger',
'aria-label': [
localizeText(title), localizeText('删除'),
localizeText('用户'), index + 1,
].join(' '),
onClick: () => updateUsers(users.filter((_, userIndex) => userIndex !== index)),
}, '删除')))))));
}
export function AccessPolicySettingsPage({ channel, account, rpcCall, onSaved }) {
const definition = ACCESS_CHANNEL_DEFINITIONS[channel];
const initialPolicy = normalizeAccessPolicy(account?.accessPolicy);
const initialKey = JSON.stringify(initialPolicy);
const [draft, setDraft] = React.useState(() => clonePolicy(
initialPolicy ?? DEFAULT_ACCESS_POLICY,
));
const [saving, setSaving] = React.useState(false);
const [feedback, setFeedback] = React.useState(null);
React.useEffect(() => {
const next = normalizeAccessPolicy(account?.accessPolicy);
setDraft(clonePolicy(next ?? DEFAULT_ACCESS_POLICY));
}, [account?.botId, initialKey]);
React.useEffect(() => {
setFeedback(null);
}, [account?.botId]);
if (!definition) {
return h('div', { className: 'dim-accessState', role: 'alert' },
'当前渠道暂不支持访问设置。');
}
const save = async (event) => {
event.preventDefault();
setFeedback(null);
setSaving(true);
try {
const policy = validateAccessPolicy(draft);
if (typeof rpcCall !== 'function') throw new Error('访问设置暂不可用。');
const value = unwrapRpcResult(await rpcCall(ACCESS_POLICY_ENDPOINT, {
botId: account.botId,
policy,
}));
const saved = policyFromSnapshot(value, account.botId);
if (!saved) throw new Error('服务没有返回已保存的访问策略,请刷新后重试。');
setDraft(clonePolicy(saved));
onSaved?.(saved);
setFeedback({ tone: 'success', message: '访问设置已保存。' });
} catch (error) {
setFeedback({
tone: 'error',
message: error?.message || '访问设置保存失败,请稍后重试。',
});
} finally {
setSaving(false);
}
};
return h('form', {
className: 'dim-accessPage',
onSubmit: (event) => void save(event),
},
initialPolicy
? null
: h('div', { className: 'dim-accessState', role: 'alert' },
'访问策略尚未就绪,请返回机器人列表刷新后重试。'),
h(ScenePolicyEditor, {
scene: 'direct',
title: '私聊',
policy: draft.direct,
userLabel: definition.directUserLabel,
placeholder: definition.directPlaceholder,
disabled: saving,
onChange: (direct) => { setDraft((current) => ({ ...current, direct })); setFeedback(null); },
}),
h(ScenePolicyEditor, {
scene: 'group',
title: '群聊',
policy: draft.group,
userLabel: definition.groupUserLabel ?? definition.directUserLabel,
placeholder: definition.groupPlaceholder ?? definition.directPlaceholder,
disabled: saving || definition.groupSupported === false,
unsupported: definition.groupSupported === false,
onChange: (group) => { setDraft((current) => ({ ...current, group })); setFeedback(null); },
}),
feedback ? h('p', {
className: 'dim-accessFeedback',
'data-tone': feedback.tone,
role: feedback.tone === 'error' ? 'alert' : 'status',
'aria-live': 'polite',
}, feedback.message) : null,
h('div', { className: 'dim-accessActions' },
h('button', {
type: 'submit',
className: 'dim-deliveryButton',
'data-kind': 'primary',
disabled: saving || !initialPolicy,
}, saving ? '正在保存…' : '保存访问设置')));
}