364 lines
17 KiB
Rust
364 lines
17 KiB
Rust
//! MNOTE 文件夹授权管理组件
|
|
|
|
use crate::routes::control_plane_status_display;
|
|
use leptos::prelude::*;
|
|
|
|
#[component]
|
|
pub fn AdminAccessPolicyPanel(
|
|
#[prop(optional)] workspace_name: Option<String>,
|
|
#[prop(optional)] share_grants_path: Option<String>,
|
|
#[prop(optional, default = true)] is_admin: bool,
|
|
#[prop(optional, default = true)] boot_script: bool,
|
|
) -> impl IntoView {
|
|
let workspace_name = workspace_name
|
|
.unwrap_or_else(|| "开发用户 的空间".to_string())
|
|
.trim()
|
|
.to_string();
|
|
let share_grants_path = share_grants_path.unwrap_or_else(control_plane_status_display);
|
|
let scope_text = if is_admin {
|
|
"管理员可以授权任意本地文件夹。"
|
|
} else {
|
|
"普通用户只能授权自己空间下的文件夹。"
|
|
};
|
|
view! {
|
|
<div
|
|
class="mnote-admin-policy-dialog__content"
|
|
data-testid="mnote-admin-access-policy-page"
|
|
data-access-policy-role={if is_admin { "admin" } else { "user" }}
|
|
>
|
|
<header class="mnote-admin-policy-header">
|
|
<div>
|
|
<div class="mnote-admin-policy-eyebrow">"授权管理"</div>
|
|
<h2 id="mnote-admin-policy-dialog-title">"文件夹授权"</h2>
|
|
<p>{scope_text}</p>
|
|
</div>
|
|
</header>
|
|
|
|
<section class="mnote-admin-policy-summary">
|
|
<div class="mnote-admin-policy-summary-item">
|
|
<span class="mnote-admin-policy-summary-label">"工作区"</span>
|
|
<strong data-testid="mnote-admin-workspace-name">{workspace_name.clone()}</strong>
|
|
</div>
|
|
<div class="mnote-admin-policy-summary-item">
|
|
<span class="mnote-admin-policy-summary-label">"控制面"</span>
|
|
<code data-testid="mnote-admin-share-grants-path">{share_grants_path.clone()}</code>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="mnote-admin-policy-grid">
|
|
<form class="mnote-admin-policy-form" data-admin-form="create-share-grant">
|
|
<header><h3>"新增文件夹授权"</h3></header>
|
|
<label>
|
|
<span>"文件夹地址"</span>
|
|
<input data-testid="mnote-admin-share-root-path" name="rootPath" type="text" placeholder="/mnt/Data1T/Mnote_data/users/..." required=true />
|
|
</label>
|
|
<label>
|
|
<span>"授权用户 ID"</span>
|
|
<input data-testid="mnote-admin-share-target-user-id" name="targetUserId" type="text" placeholder="user_123" required=true />
|
|
</label>
|
|
<label>
|
|
<span>"权限"</span>
|
|
<select data-testid="mnote-admin-share-permission" name="sharePermission">
|
|
<option value="read">"read"</option>
|
|
<option value="write">"write"</option>
|
|
</select>
|
|
</label>
|
|
<button type="submit" data-testid="mnote-admin-create-share-grant-submit">"创建授权"</button>
|
|
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-create-share-grant-result"></pre>
|
|
</form>
|
|
|
|
<section class="mnote-admin-policy-panel" data-testid="mnote-admin-share-grants-panel">
|
|
<header class="mnote-admin-policy-panel-header">
|
|
<div>
|
|
<h3>"授权列表"</h3>
|
|
<p class="mnote-admin-policy-note">"查看当前可管理的文件夹授权,并撤销不再需要的授权。"</p>
|
|
</div>
|
|
<button type="button" data-testid="mnote-admin-share-grants-refresh" data-admin-action="refresh-share-grants">"刷新"</button>
|
|
</header>
|
|
<div class="mnote-admin-policy-table" data-testid="mnote-admin-share-grants-list">
|
|
<div class="mnote-admin-policy-empty">"正在读取授权..."</div>
|
|
</div>
|
|
<details class="mnote-admin-policy-debug">
|
|
<summary>"查看授权 JSON"</summary>
|
|
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-share-grants-json">{""}</pre>
|
|
</details>
|
|
<div class="mnote-admin-policy-note" data-testid="mnote-admin-share-grants-message"></div>
|
|
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-delete-share-grant-result" hidden></pre>
|
|
</section>
|
|
</section>
|
|
<script id="__MNOTE_ACCESS_POLICY_PAGE__" type="application/json">
|
|
{format!(r#"{{"isAdmin":{}}}"#, if is_admin { "true" } else { "false" })}
|
|
</script>
|
|
{if boot_script {
|
|
view! { <script>{ADMIN_POLICY_SCRIPT}</script> }.into_any()
|
|
} else {
|
|
view! {}.into_any()
|
|
}}
|
|
</div>
|
|
}
|
|
}
|
|
|
|
pub(crate) const ADMIN_POLICY_SCRIPT: &str = r#"
|
|
(function () {
|
|
function initAccessPolicyPanel(scope) {
|
|
var root = (scope || document).querySelector('[data-testid="mnote-admin-access-policy-page"]');
|
|
if (!root) return;
|
|
if (root.getAttribute('data-access-policy-ready') === 'true') return;
|
|
root.setAttribute('data-access-policy-ready', 'true');
|
|
var shareGrantsJson = root.querySelector('[data-testid="mnote-admin-share-grants-json"]');
|
|
var shareGrantsMessage = root.querySelector('[data-testid="mnote-admin-share-grants-message"]');
|
|
var createShareGrantResult = root.querySelector('[data-testid="mnote-admin-create-share-grant-result"]');
|
|
var deleteShareGrantResult = root.querySelector('[data-testid="mnote-admin-delete-share-grant-result"]');
|
|
var refreshShareGrantsButton = root.querySelector('[data-admin-action="refresh-share-grants"]');
|
|
var shareGrantsList = root.querySelector('[data-testid="mnote-admin-share-grants-list"]');
|
|
var currentActorId = document.body && document.body.getAttribute ? String(document.body.getAttribute('data-mnote-actor-id') || '').trim() : '';
|
|
var pageConfig = (function () {
|
|
var node = root.querySelector('#__MNOTE_ACCESS_POLICY_PAGE__');
|
|
try { return JSON.parse(node ? node.textContent || '{}' : '{}'); } catch (_) { return {}; }
|
|
})();
|
|
var isAdmin = pageConfig.isAdmin === true;
|
|
|
|
function setText(node, value) {
|
|
if (!node) return;
|
|
node.textContent = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value == null ? '' : value)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function renderBadge(value) {
|
|
var text = String(value || '').trim() || 'read';
|
|
return '<span class="mnote-admin-policy-badge" data-value="' + escapeHtml(text) + '">' + escapeHtml(text) + '</span>';
|
|
}
|
|
|
|
function readDirectoryGrants(payload) {
|
|
if (payload && Array.isArray(payload.grants)) return payload.grants;
|
|
if (payload && payload.policy && Array.isArray(payload.policy.grants)) return payload.policy.grants;
|
|
return [];
|
|
}
|
|
|
|
function accessPolicyUrl(suffix) {
|
|
var base = isAdmin ? '/api/admin/access-policy' : '/api/user/access-policy';
|
|
return suffix ? base + suffix : base;
|
|
}
|
|
|
|
function pathToFileRootUri(value) {
|
|
var trimmed = String(value || '').trim();
|
|
if (!trimmed) return '';
|
|
if (/^file:\/\//i.test(trimmed)) return trimmed;
|
|
if (trimmed.charAt(0) !== '/') return '';
|
|
return 'file://' + trimmed.split('/').map(function(part, index) {
|
|
return index === 0 ? '' : encodeURIComponent(part);
|
|
}).join('/');
|
|
}
|
|
|
|
function localFolderOpenHref(grant) {
|
|
var rootUri = String(grant.rootUri || '').trim();
|
|
if (rootUri && !/^file:\/\//i.test(rootUri)) rootUri = '';
|
|
if (!rootUri) {
|
|
var rootPath = String(grant.rootPath || '').trim();
|
|
if (rootPath) rootUri = pathToFileRootUri(rootPath);
|
|
}
|
|
if (!rootUri) {
|
|
rootUri = pathToFileRootUri(String(grant.rootUri || '').trim());
|
|
}
|
|
if (!rootUri) return '';
|
|
var url = new URL('/', window.location.origin);
|
|
url.searchParams.set('treeView', 'filetree');
|
|
url.searchParams.set('sourceKind', 'local_folder');
|
|
url.searchParams.set('rootUri', rootUri);
|
|
return url.pathname + url.search;
|
|
}
|
|
|
|
function isDefaultWorkspaceAutoGrant(grant) {
|
|
var source = String(grant && grant.source || '').trim();
|
|
var workspaceId = String(grant && grant.workspaceId || '').trim();
|
|
var rootUri = String(grant && grant.rootUri || '').trim();
|
|
var permission = String(grant && grant.permission || '').trim();
|
|
var createdBy = String(grant && (grant.createdBy || grant.ownerUserId) || '').trim();
|
|
var targetUser = String(grant && (grant.userId || grant.targetUserId) || '').trim();
|
|
return source === 'auto'
|
|
&& workspaceId
|
|
&& permission === 'write'
|
|
&& createdBy === targetUser
|
|
&& /^local:\/\/users\/.+\/workspaces\/my-space$/.test(rootUri);
|
|
}
|
|
|
|
function renderShareGrants(payload) {
|
|
if (!shareGrantsList) return;
|
|
var grants = readDirectoryGrants(payload);
|
|
if (!grants.length) {
|
|
shareGrantsList.innerHTML = '<div class="mnote-admin-policy-empty">暂无文件夹授权</div>';
|
|
return;
|
|
}
|
|
shareGrantsList.innerHTML = grants.map(function(grant) {
|
|
var active = grant.active === false || grant.status === 'revoked' ? '已撤销' : '有效';
|
|
var openHref = localFolderOpenHref(grant);
|
|
var rootLabel = escapeHtml(grant.rootPath || grant.rootUri || '');
|
|
var createdBy = String(grant.createdBy || grant.ownerUserId || '').trim();
|
|
var systemOwned = isDefaultWorkspaceAutoGrant(grant);
|
|
var canRevoke = !systemOwned && (isAdmin || (createdBy && currentActorId && createdBy === currentActorId));
|
|
var rootNode = openHref
|
|
? '<a class="mnote-admin-policy-root-link" data-testid="mnote-admin-open-granted-root" href="' + escapeHtml(openHref) + '">' + rootLabel + '</a>'
|
|
: '<strong>' + rootLabel + '</strong>';
|
|
var revokeButton = grant.active === false || !canRevoke ? '' :
|
|
'<button type="button" data-admin-action="revoke-access-grant" data-grant-id="' + escapeHtml(grant.id || '') + '">撤销</button>';
|
|
return '<article class="mnote-admin-policy-row">' +
|
|
'<div>' + rootNode + '<span>授权用户 ' + escapeHtml(grant.targetUserId || grant.userId || '') + '</span></div>' +
|
|
'<div>' + renderBadge(grant.permission) + renderBadge(active) + '</div>' +
|
|
'<div><span>创建者 ' + escapeHtml(createdBy) + '</span><span>授权 ID ' + escapeHtml(grant.id || '') + '</span>' + revokeButton + '</div>' +
|
|
'</article>';
|
|
}).join('');
|
|
}
|
|
|
|
function shareGrantFormValues(form) {
|
|
var data = new FormData(form);
|
|
return {
|
|
userId: String(data.get('targetUserId') || '').trim(),
|
|
targetUserId: String(data.get('targetUserId') || '').trim(),
|
|
rootPath: String(data.get('rootPath') || '').trim(),
|
|
permission: String(data.get('sharePermission') || 'read').trim(),
|
|
};
|
|
}
|
|
|
|
async function requestJson(url, options) {
|
|
var response = await fetch(url, {
|
|
credentials: 'include',
|
|
headers: { 'content-type': 'application/json' },
|
|
...options,
|
|
});
|
|
var payload = await response.json().catch(function () { return {}; });
|
|
if (!response.ok || payload.ok === false) {
|
|
throw new Error((payload && payload.message) || ('请求失败: ' + response.status));
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
async function refreshShareGrants() {
|
|
var payload = await requestJson(accessPolicyUrl(''), { method: 'GET', headers: {} });
|
|
setText(shareGrantsJson, payload);
|
|
renderShareGrants(payload);
|
|
setText(shareGrantsMessage, '已刷新授权列表');
|
|
}
|
|
|
|
refreshShareGrantsButton && refreshShareGrantsButton.addEventListener('click', function () {
|
|
setText(shareGrantsMessage, '正在刷新授权列表...');
|
|
refreshShareGrants().catch(function (error) { setText(shareGrantsMessage, error.message || '刷新失败'); });
|
|
});
|
|
|
|
root.querySelector('[data-admin-form="create-share-grant"]').addEventListener('submit', function (event) {
|
|
event.preventDefault();
|
|
var form = event.currentTarget;
|
|
var values = shareGrantFormValues(form);
|
|
setText(createShareGrantResult, '正在创建...');
|
|
requestJson(accessPolicyUrl('/grants'), {
|
|
method: 'POST',
|
|
body: JSON.stringify(values),
|
|
}).then(function (payload) {
|
|
setText(createShareGrantResult, payload);
|
|
setText(shareGrantsMessage, '文件夹授权已创建');
|
|
form.reset();
|
|
return refreshShareGrants();
|
|
}).catch(function (error) {
|
|
setText(createShareGrantResult, { ok: false, error: error.message || '创建失败' });
|
|
setText(shareGrantsMessage, error.message || '创建失败');
|
|
});
|
|
});
|
|
|
|
shareGrantsList && shareGrantsList.addEventListener('click', function(event) {
|
|
var button = event.target && event.target.closest ? event.target.closest('[data-admin-action="revoke-access-grant"]') : null;
|
|
if (!button) return;
|
|
event.preventDefault();
|
|
var grantId = String(button.getAttribute('data-grant-id') || '').trim();
|
|
if (!grantId) return;
|
|
setText(deleteShareGrantResult, '正在撤销...');
|
|
requestJson(accessPolicyUrl('/grants/') + encodeURIComponent(grantId), {
|
|
method: 'DELETE',
|
|
headers: {},
|
|
}).then(function (payload) {
|
|
setText(deleteShareGrantResult, payload);
|
|
setText(shareGrantsMessage, '文件夹授权已撤销');
|
|
return refreshShareGrants();
|
|
}).catch(function (error) {
|
|
setText(deleteShareGrantResult, { ok: false, error: error.message || '撤销失败' });
|
|
setText(shareGrantsMessage, error.message || '撤销失败');
|
|
});
|
|
});
|
|
|
|
refreshShareGrants().catch(function (error) {
|
|
setText(shareGrantsMessage, error.message || '加载授权失败');
|
|
renderShareGrants(null);
|
|
});
|
|
}
|
|
window.MNOTEInitAccessPolicyPanel = initAccessPolicyPanel;
|
|
var shell = document.body && document.body.getAttribute('data-mnote-shell');
|
|
if (shell === 'admin' || shell === 'user-access-policy') {
|
|
initAccessPolicyPanel(document);
|
|
}
|
|
})();
|
|
"#;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::ssr::render_view;
|
|
|
|
#[test]
|
|
fn admin_access_policy_panel_renders_minimal_folder_grant_controls() {
|
|
let html = render_view(view! {
|
|
<AdminAccessPolicyPanel
|
|
workspace_name={"我的空间".to_string()}
|
|
is_admin=true
|
|
/>
|
|
});
|
|
assert!(html.contains("mnote-admin-access-policy-page"));
|
|
assert!(html.contains("文件夹授权"));
|
|
assert!(html.contains("文件夹地址"));
|
|
assert!(html.contains("授权用户 ID"));
|
|
assert!(html.contains("read"));
|
|
assert!(html.contains("write"));
|
|
assert!(html.contains("mnote-admin-share-grants-panel"));
|
|
assert!(html.contains("mnote-admin-share-grants-json"));
|
|
assert!(html.contains("mnote-admin-create-share-grant-submit"));
|
|
assert!(html.contains("/api/admin/access-policy"));
|
|
assert!(html.contains("/api/user/access-policy"));
|
|
assert!(html.contains("data-mnote-shell"));
|
|
assert!(!html.contains("/api/admin/share-grants"));
|
|
assert!(!html.contains("/api/user/share-grants"));
|
|
assert!(
|
|
html.contains("data-admin-action="revoke-access-grant"")
|
|
|| html.contains("revoke-access-grant")
|
|
);
|
|
assert!(!html.contains("name=\"grantId\""));
|
|
assert!(!html.contains("name=\"shareId\""));
|
|
assert!(!html.contains("share_xxx"));
|
|
assert!(!html.contains("name=\"ownerUserId\""));
|
|
assert!(!html.contains("documentId"));
|
|
assert!(!html.contains("allowedResourceIds"));
|
|
assert!(!html.contains("capabilities"));
|
|
assert!(!html.contains("mnote-admin-validate-root-submit"));
|
|
}
|
|
|
|
#[test]
|
|
fn admin_policy_script_keeps_form_reference_for_async_reset() {
|
|
assert!(ADMIN_POLICY_SCRIPT.contains("var form = event.currentTarget;"));
|
|
assert!(ADMIN_POLICY_SCRIPT.contains("form.reset();"));
|
|
assert!(!ADMIN_POLICY_SCRIPT.contains("event.currentTarget.reset();"));
|
|
}
|
|
|
|
#[test]
|
|
fn admin_policy_script_links_grants_to_local_folder_entry() {
|
|
assert!(ADMIN_POLICY_SCRIPT.contains("function localFolderOpenHref(grant)"));
|
|
assert!(ADMIN_POLICY_SCRIPT.contains("sourceKind', 'local_folder'"));
|
|
assert!(ADMIN_POLICY_SCRIPT.contains("data-testid=\"mnote-admin-open-granted-root\""));
|
|
assert!(ADMIN_POLICY_SCRIPT.contains("function isDefaultWorkspaceAutoGrant(grant)"));
|
|
assert!(ADMIN_POLICY_SCRIPT.contains("var canRevoke = !systemOwned &&"));
|
|
}
|
|
}
|