Fix MCP permissions and repayments; restrict deletion to default admin
This commit is contained in:
1 parent
35bd2e1828
commit
794274d31b
21 files changed
+702
-52
No files matched your search
@@ -85,7 +85,11 @@ export function AccountPicker({
|
||||
{p.iconId && <img src={iconUrl(p.iconId)} alt="" />}
|
||||
<span>
|
||||
{p.name}
|
||||
<small>{money(positionAmount(p), p.currency)}</small>
|
||||
<small
|
||||
className={positionAmount(p).startsWith('-') ? 'danger-text' : 'income-text'}
|
||||
>
|
||||
{money(positionAmount(p), p.currency)}
|
||||
</small>
|
||||
</span>
|
||||
{p.id === value && <span>✓</span>}
|
||||
</button>
|
||||
|
||||
+19
-15
@@ -183,18 +183,20 @@ export function AdminPanel({ user, report }: { user: User; report: (e: unknown)
|
||||
>
|
||||
{account.banned ? tr('解除封禁') : tr('封禁账号')}
|
||||
</button>
|
||||
{account.role === 'admin' && account.id !== user.id && (
|
||||
<button
|
||||
className="danger"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setDeleting(account);
|
||||
setConfirmation('');
|
||||
}}
|
||||
>
|
||||
{tr('删除管理员')}
|
||||
</button>
|
||||
)}
|
||||
{account.username === 'admin' &&
|
||||
account.role === 'admin' &&
|
||||
account.id !== user.id && (
|
||||
<button
|
||||
className="danger"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setDeleting(account);
|
||||
setConfirmation('');
|
||||
}}
|
||||
>
|
||||
{tr('删除默认 admin')}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -220,9 +222,9 @@ export function AdminPanel({ user, report }: { user: User; report: (e: unknown)
|
||||
</section>
|
||||
{deleting && (
|
||||
<div className="veil">
|
||||
<section className="modal" role="dialog" aria-modal="true" aria-label={tr('删除管理员')}>
|
||||
<section className="modal" role="dialog" aria-modal="true" aria-label={tr('删除账号')}>
|
||||
<h2>
|
||||
{tr('删除管理员')} · {deleting.username}
|
||||
{tr('删除账号')} · {deleting.username}
|
||||
</h2>
|
||||
<p className="danger-text">
|
||||
{tr(
|
||||
@@ -230,7 +232,9 @@ export function AdminPanel({ user, report }: { user: User; report: (e: unknown)
|
||||
)}
|
||||
</p>
|
||||
<p className="muted">
|
||||
{tr('必须保留另一位已完成改密且未封禁的管理员。不能删除当前登录账号。')}
|
||||
{tr(
|
||||
'仅允许删除默认 admin 账号;其他账号禁止删除。必须保留另一位已完成改密且未封禁的管理员,不能删除当前登录账号。',
|
||||
)}
|
||||
</p>
|
||||
<form onSubmit={remove}>
|
||||
<label className="field">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from './api';
|
||||
import { AgentDrafts } from './AgentDrafts';
|
||||
import { AgentDrafts, ReviewDialog } from './AgentDrafts';
|
||||
import { toolLabel, statusLabel, permissionLabel } from './agent-display';
|
||||
import { showToast, useToast } from './Toast';
|
||||
|
||||
@@ -118,6 +118,10 @@ function codexSetupPrompt(url: string, level: string, hiddenRead = false, hidden
|
||||
|
||||
const connectionExpiry = (g: Connection) => (g.clientId ? g.refreshExpiresAt : g.expiresAt);
|
||||
export function AgentConnections({ changed }: { changed?: () => void }) {
|
||||
const [editing, setEditing] = useState<Connection | null>(null);
|
||||
const [editPermission, setEditPermission] = useState('read');
|
||||
const [editHiddenRead, setEditHiddenRead] = useState(false);
|
||||
const [editHiddenWrite, setEditHiddenWrite] = useState(false);
|
||||
const [review, setReview] = useState<string | null>(null);
|
||||
const [data, setData] = useState<Management | null>(null),
|
||||
[view, setView] = useState('connect'),
|
||||
@@ -322,7 +326,9 @@ export function AgentConnections({ changed }: { changed?: () => void }) {
|
||||
<>
|
||||
<section className="agent-card">
|
||||
<h3>已授权连接</h3>
|
||||
<p className="muted">最近 100 条 · 撤销后助手将无法继续访问。</p>
|
||||
<p className="muted">
|
||||
最近 100 条 · 可修改权限,OAuth 也支持直接写入;撤销后助手无法继续访问。
|
||||
</p>
|
||||
{!data.grants.length && (
|
||||
<div className="agent-empty">
|
||||
<h4>还没有连接</h4>
|
||||
@@ -357,18 +363,40 @@ export function AgentConnections({ changed }: { changed?: () => void }) {
|
||||
</small>
|
||||
</div>
|
||||
{!g.revokedAt && (
|
||||
<button
|
||||
className="secondary"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void act(async () => {
|
||||
await api('/agent/connections/' + g.id, 'DELETE');
|
||||
showToast('连接已撤销', 'success');
|
||||
})
|
||||
}
|
||||
>
|
||||
撤销连接
|
||||
</button>
|
||||
<div className="actions">
|
||||
{active.some((a) => a.id === g.id) && (
|
||||
<button
|
||||
className="secondary"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setEditing(g);
|
||||
setEditPermission(
|
||||
g.scopes.includes('write')
|
||||
? 'write'
|
||||
: g.scopes.includes('draft')
|
||||
? 'draft'
|
||||
: 'read',
|
||||
);
|
||||
setEditHiddenRead(g.scopes.includes('hidden_read'));
|
||||
setEditHiddenWrite(g.scopes.includes('hidden_write'));
|
||||
}}
|
||||
>
|
||||
修改权限
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="secondary"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void act(async () => {
|
||||
await api('/agent/connections/' + g.id, 'DELETE');
|
||||
showToast('连接已撤销', 'success');
|
||||
})
|
||||
}
|
||||
>
|
||||
撤销连接
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -591,6 +619,104 @@ export function AgentConnections({ changed }: { changed?: () => void }) {
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
{editing && (
|
||||
<ReviewDialog title="修改连接权限" close={() => setEditing(null)} busy={busy}>
|
||||
<p>
|
||||
<strong>{editing.name}</strong> · {editing.clientId ? 'OAuth' : '个人令牌'}
|
||||
</p>
|
||||
<p className="muted">
|
||||
保存后权限立即生效,授权期限保持不变。修改权限会取消该连接未确认的草稿,需要重新创建。
|
||||
</p>
|
||||
<form
|
||||
className="agent-form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const f = new FormData(e.currentTarget);
|
||||
void act(async () => {
|
||||
await api('/agent/connections/' + editing.id, 'PATCH', {
|
||||
password: f.get('password'),
|
||||
scopes: [
|
||||
'read',
|
||||
...(editPermission === 'read' ? [] : [editPermission]),
|
||||
...(editHiddenRead ? ['hidden_read'] : []),
|
||||
...(editHiddenRead && editHiddenWrite && editPermission !== 'read'
|
||||
? ['hidden_write']
|
||||
: []),
|
||||
],
|
||||
});
|
||||
setEditing(null);
|
||||
showToast('连接权限已更新', 'success');
|
||||
});
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
连接权限
|
||||
<select
|
||||
value={editPermission}
|
||||
onChange={(e) => {
|
||||
setEditPermission(e.target.value);
|
||||
if (e.target.value === 'read') setEditHiddenWrite(false);
|
||||
}}
|
||||
>
|
||||
<option value="read">只读查询</option>
|
||||
<option value="draft">草稿修改</option>
|
||||
<option value="write">直接写入</option>
|
||||
</select>
|
||||
</label>
|
||||
{editPermission === 'write' && (
|
||||
<p className="danger-text">
|
||||
直接写入会立即执行普通修改,无需逐次网页确认。请仅授予你信任的助手。
|
||||
</p>
|
||||
)}
|
||||
<fieldset className="agent-scope-options">
|
||||
<legend>隐藏账户权限</legend>
|
||||
<label className="check-line">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editHiddenRead}
|
||||
onChange={(e) => {
|
||||
setEditHiddenRead(e.target.checked);
|
||||
if (!e.target.checked) setEditHiddenWrite(false);
|
||||
}}
|
||||
/>
|
||||
允许读取隐藏账户
|
||||
</label>
|
||||
<label className="check-line">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editHiddenWrite}
|
||||
disabled={!editHiddenRead || editPermission === 'read'}
|
||||
onChange={(e) => setEditHiddenWrite(e.target.checked)}
|
||||
/>
|
||||
允许修改隐藏账户
|
||||
</label>
|
||||
</fieldset>
|
||||
<label>
|
||||
验证当前密码
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
maxLength={72}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
<div className="actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
disabled={busy}
|
||||
onClick={() => setEditing(null)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button className="primary" disabled={busy}>
|
||||
保存权限
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</ReviewDialog>
|
||||
)}
|
||||
{review !== null && (
|
||||
<AgentDrafts
|
||||
initialId={review === 'all' ? undefined : review}
|
||||
|
||||
@@ -9,10 +9,12 @@ export function ReviewDialog({
|
||||
children,
|
||||
close,
|
||||
busy = false,
|
||||
title = '审阅草稿',
|
||||
}: {
|
||||
children: ReactNode;
|
||||
close: () => void;
|
||||
busy?: boolean;
|
||||
title?: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
useEffect(() => {
|
||||
@@ -38,7 +40,7 @@ export function ReviewDialog({
|
||||
className="modal draft-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="审阅草稿"
|
||||
aria-label={title}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape' && !busy) {
|
||||
e.preventDefault();
|
||||
@@ -73,7 +75,7 @@ export function ReviewDialog({
|
||||
}}
|
||||
>
|
||||
<header>
|
||||
<h2>审阅草稿</h2>
|
||||
<h2>{title}</h2>
|
||||
<button className="icon" aria-label="关闭" disabled={busy} onClick={close}>
|
||||
×
|
||||
</button>
|
||||
|
||||
+14
-6
@@ -1260,7 +1260,7 @@ export default function App() {
|
||||
</div>
|
||||
<strong
|
||||
className={
|
||||
cls === 'debt' || value.startsWith('-') ? 'danger-text' : undefined
|
||||
cls === 'debt' || value.startsWith('-') ? 'danger-text' : 'income-text'
|
||||
}
|
||||
>
|
||||
{money(value, user.baseCurrency)}
|
||||
@@ -1468,7 +1468,9 @@ export default function App() {
|
||||
)}
|
||||
{p.archived ? tr(' · 已归档') : ''}
|
||||
</span>
|
||||
<h2 className={positionAmount(p).startsWith('-') ? 'danger-text' : undefined}>
|
||||
<h2
|
||||
className={positionAmount(p).startsWith('-') ? 'danger-text' : 'income-text'}
|
||||
>
|
||||
{p.valuationAvailable === false
|
||||
? tr('待估值')
|
||||
: money(positionAmount(p), p.currency)}
|
||||
@@ -1922,7 +1924,7 @@ export default function App() {
|
||||
</p>
|
||||
<strong
|
||||
className={
|
||||
positionAmount(p).startsWith('-') ? 'danger-text' : undefined
|
||||
positionAmount(p).startsWith('-') ? 'danger-text' : 'income-text'
|
||||
}
|
||||
>
|
||||
{p.valuationAvailable === false
|
||||
@@ -3474,17 +3476,23 @@ function HistoryTable({
|
||||
</small>
|
||||
</td>
|
||||
<td
|
||||
className={positionAmount(h, h.before).startsWith('-') ? 'danger-text' : undefined}
|
||||
className={
|
||||
positionAmount(h, h.before).startsWith('-') ? 'danger-text' : 'income-text'
|
||||
}
|
||||
>
|
||||
{money(positionAmount(h, h.before), h.currency)}
|
||||
</td>
|
||||
<td
|
||||
className={positionAmount(h, h.after).startsWith('-') ? 'danger-text' : undefined}
|
||||
className={
|
||||
positionAmount(h, h.after).startsWith('-') ? 'danger-text' : 'income-text'
|
||||
}
|
||||
>
|
||||
{money(positionAmount(h, h.after), h.currency)}
|
||||
</td>
|
||||
<td
|
||||
className={positionAmount(h, h.delta).startsWith('-') ? 'danger-text' : undefined}
|
||||
className={
|
||||
positionAmount(h, h.delta).startsWith('-') ? 'danger-text' : 'income-text'
|
||||
}
|
||||
>
|
||||
{money(positionAmount(h, h.delta), h.currency)}
|
||||
</td>
|
||||
|
||||
@@ -137,7 +137,7 @@ export function QuickRepayment({
|
||||
const source = choices.find((p) => p.id === sourceId);
|
||||
return (
|
||||
<section>
|
||||
<p>
|
||||
<p className={positionAmount(target).startsWith('-') ? 'danger-text' : 'income-text'}>
|
||||
{tr('还款账户')}:{target.name} · {money(positionAmount(target), target.currency)}
|
||||
</p>
|
||||
<p className="muted">
|
||||
|
||||
@@ -1276,7 +1276,7 @@ footer {
|
||||
color: #c73542 !important;
|
||||
}
|
||||
.income-text {
|
||||
color: #1b7855;
|
||||
color: #1b7855 !important;
|
||||
}
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
|
||||
Reference in new issue
Block a user