feat(page-data): owner CRUD, public read, and role policy UI
Expose owner PATCH/soft-delete routes, allow anonymous read/stats on public pages when policy enables read, and add login_required role editor to the ops panel with tests and doc updates. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,9 +6,30 @@ import {
|
||||
resetPageDataPassword,
|
||||
restorePageDataRow,
|
||||
revokePageDataTokens,
|
||||
savePageDataPolicy,
|
||||
} from '../api/client';
|
||||
import type { MindSpacePublishCheck, PageDataOpsOverview } from '../types';
|
||||
|
||||
type RowPolicyScope = 'all_rows' | 'own_rows' | 'owner_only';
|
||||
|
||||
function formatVisitorsText(visitors: Record<string, 'viewer' | 'editor'> | undefined) {
|
||||
return Object.entries(visitors ?? {})
|
||||
.map(([userId, role]) => `${userId} ${role}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function parseVisitorsText(text: string) {
|
||||
const visitors: Record<string, 'viewer' | 'editor'> = {};
|
||||
for (const line of text.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const match = trimmed.match(/^(\S+)[\s:]+(viewer|editor)$/i);
|
||||
if (!match) continue;
|
||||
visitors[match[1]] = match[2].toLowerCase() as 'viewer' | 'editor';
|
||||
}
|
||||
return visitors;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
pageId: string;
|
||||
pageTitle: string;
|
||||
@@ -29,6 +50,24 @@ export function MindSpacePageDataOpsPanel({ pageId, pageTitle, accessMode, onClo
|
||||
const [busyAction, setBusyAction] = useState<string | null>(null);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [defaultVisitorRole, setDefaultVisitorRole] = useState<'deny' | 'viewer' | 'editor'>('deny');
|
||||
const [visitorsText, setVisitorsText] = useState('');
|
||||
const [rowPolicies, setRowPolicies] = useState<
|
||||
Record<string, { scope: RowPolicyScope; ownerColumn: string }>
|
||||
>({});
|
||||
|
||||
const syncRoleForm = useCallback((policy: PageDataOpsOverview['policy']) => {
|
||||
setDefaultVisitorRole(policy.defaultVisitorRole ?? 'deny');
|
||||
setVisitorsText(formatVisitorsText(policy.visitors));
|
||||
const next: Record<string, { scope: RowPolicyScope; ownerColumn: string }> = {};
|
||||
for (const [name, dataset] of Object.entries(policy.datasets ?? {})) {
|
||||
next[name] = {
|
||||
scope: dataset.rowPolicy?.scope ?? 'all_rows',
|
||||
ownerColumn: dataset.rowPolicy?.ownerColumn ?? 'created_by_user_id',
|
||||
};
|
||||
}
|
||||
setRowPolicies(next);
|
||||
}, []);
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -36,6 +75,7 @@ export function MindSpacePageDataOpsPanel({ pageId, pageTitle, accessMode, onClo
|
||||
try {
|
||||
const next = await getPageDataOpsOverview(pageId);
|
||||
setOverview(next);
|
||||
syncRoleForm(next.policy);
|
||||
} catch (err) {
|
||||
const text = err instanceof Error ? err.message : '加载页面数据运维信息失败';
|
||||
if (text.includes('policy_not_found') || text.includes('策略未配置')) {
|
||||
@@ -47,7 +87,7 @@ export function MindSpacePageDataOpsPanel({ pageId, pageTitle, accessMode, onClo
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [pageId]);
|
||||
}, [pageId, syncRoleForm]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadOverview();
|
||||
@@ -129,6 +169,113 @@ export function MindSpacePageDataOpsPanel({ pageId, pageTitle, accessMode, onClo
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{overview.publication.accessMode === 'login_required' ? (
|
||||
<div className="mindspace-page-data-ops-section mindspace-page-data-ops-role-editor">
|
||||
<h4>登录访问 · 角色与行级策略</h4>
|
||||
<label>
|
||||
未列名访问者默认角色
|
||||
<select
|
||||
value={defaultVisitorRole}
|
||||
disabled={busyAction != null}
|
||||
onChange={(event) =>
|
||||
setDefaultVisitorRole(event.target.value as 'deny' | 'viewer' | 'editor')
|
||||
}
|
||||
>
|
||||
<option value="deny">拒绝</option>
|
||||
<option value="viewer">只读 (viewer)</option>
|
||||
<option value="editor">编辑 (editor)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
指定访问者(每行:用户 ID + 空格 + viewer/editor)
|
||||
<textarea
|
||||
value={visitorsText}
|
||||
disabled={busyAction != null}
|
||||
rows={4}
|
||||
placeholder={'user-a editor\nuser-b viewer'}
|
||||
onChange={(event) => setVisitorsText(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{overview.datasets.length > 0 ? (
|
||||
<div className="mindspace-page-data-ops-role-datasets">
|
||||
<span>Dataset 行级范围</span>
|
||||
{overview.datasets.map((dataset) => (
|
||||
<div key={dataset.name} className="mindspace-page-data-ops-role-dataset-row">
|
||||
<code>{dataset.name}</code>
|
||||
<select
|
||||
value={rowPolicies[dataset.name]?.scope ?? 'all_rows'}
|
||||
disabled={busyAction != null}
|
||||
onChange={(event) =>
|
||||
setRowPolicies((current) => ({
|
||||
...current,
|
||||
[dataset.name]: {
|
||||
scope: event.target.value as RowPolicyScope,
|
||||
ownerColumn: current[dataset.name]?.ownerColumn ?? 'created_by_user_id',
|
||||
},
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="all_rows">全部行</option>
|
||||
<option value="own_rows">仅自己的行</option>
|
||||
<option value="owner_only">仅 owner</option>
|
||||
</select>
|
||||
{(rowPolicies[dataset.name]?.scope ?? 'all_rows') === 'own_rows' ? (
|
||||
<input
|
||||
value={rowPolicies[dataset.name]?.ownerColumn ?? 'created_by_user_id'}
|
||||
disabled={busyAction != null}
|
||||
placeholder="owner 列名"
|
||||
onChange={(event) =>
|
||||
setRowPolicies((current) => ({
|
||||
...current,
|
||||
[dataset.name]: {
|
||||
scope: current[dataset.name]?.scope ?? 'own_rows',
|
||||
ownerColumn: event.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyAction != null}
|
||||
onClick={() =>
|
||||
void runAction('save-role-policy', async () => {
|
||||
if (!overview?.policy) return;
|
||||
const datasets = { ...overview.policy.datasets };
|
||||
for (const [name, rowPolicy] of Object.entries(rowPolicies)) {
|
||||
if (!datasets[name]) continue;
|
||||
datasets[name] = {
|
||||
...datasets[name],
|
||||
rowPolicy:
|
||||
rowPolicy.scope === 'all_rows'
|
||||
? undefined
|
||||
: {
|
||||
scope: rowPolicy.scope,
|
||||
ownerColumn:
|
||||
rowPolicy.scope === 'own_rows'
|
||||
? rowPolicy.ownerColumn.trim() || 'created_by_user_id'
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
await savePageDataPolicy(pageId, {
|
||||
defaultVisitorRole,
|
||||
visitors: parseVisitorsText(visitorsText),
|
||||
datasets,
|
||||
});
|
||||
setMessage('角色与行级策略已保存');
|
||||
})
|
||||
}
|
||||
>
|
||||
{busyAction === 'save-role-policy' ? '保存中…' : '保存角色策略'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mindspace-page-data-ops-section">
|
||||
<h4>Dataset 状态</h4>
|
||||
{overview.datasets.length === 0 ? (
|
||||
|
||||
Reference in New Issue
Block a user