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:
john
2026-07-08 14:54:14 +08:00
parent 6b0c633a75
commit 8d629e7a4e
11 changed files with 360 additions and 9 deletions
+148 -1
View File
@@ -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 ? (