feat: access control UI in Settings + sidebar drag-and-drop reordering
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 1m13s

- Add Access Control card in Settings (admin-only): role defaults display, user overrides CRUD
- Add sidebar drag-and-drop reordering with per-user persistence in rbac.json
- Backend: GET/PUT /api/auth/preferences endpoints, sidebar order helpers in rbac.py
- Frontend: HTML5 drag API with grip handles, smart order merge, debounced save
- Preserve sidebar_order when updating page overrides
This commit is contained in:
Gan, Jimmy
2026-03-01 14:48:35 +08:00
parent 9992105b49
commit a8debcfb4b
6 changed files with 334 additions and 20 deletions
+165 -3
View File
@@ -1,6 +1,7 @@
<script>
import { post, get } from "../lib/api.js";
import { post, get, del, put } from "../lib/api.js";
import { onMount } from "svelte";
import { currentUser } from "../lib/api.js";
let currentPassword = $state("");
let newPassword = $state("");
@@ -14,8 +15,6 @@
let passkeyErr = $state("");
let passkeyLoading = $state(false);
onMount(loadPasskeys);
async function loadPasskeys() {
try {
const res = await get("/auth/passkey/list");
@@ -131,6 +130,79 @@
} catch {}
auditLoading = false;
}
// Access Control
const allPages = ["dashboard", "docker", "files", "terminal", "security", "openclaw", "chat-digest", "gitea"];
let rbacConfig = $state(null);
let rbacLoading = $state(false);
let rbacMsg = $state("");
let rbacErr = $state("");
let editingUser = $state("");
let editPages = $state([]);
let newUsername = $state("");
async function loadRbac() {
rbacLoading = true;
try {
rbacConfig = await get("/auth/rbac/config");
} catch {}
rbacLoading = false;
}
function startEdit(username, pages) {
editingUser = username;
editPages = [...pages];
newUsername = "";
}
function startAdd() {
editingUser = "__new__";
editPages = ["dashboard"];
newUsername = "";
}
function cancelEdit() {
editingUser = "";
editPages = [];
newUsername = "";
}
function togglePage(p) {
if (editPages.includes(p)) editPages = editPages.filter(x => x !== p);
else editPages = [...editPages, p];
}
async function saveOverride() {
rbacMsg = ""; rbacErr = "";
const uname = editingUser === "__new__" ? newUsername.trim() : editingUser;
if (!uname) { rbacErr = "Username required"; return; }
if (!editPages.length) { rbacErr = "Select at least one page"; return; }
try {
await put(`/auth/rbac/overrides/${encodeURIComponent(uname)}`, { pages: editPages });
rbacMsg = `Saved override for ${uname}`;
cancelEdit();
await loadRbac();
} catch (e) {
rbacErr = e.body?.detail || "Failed to save";
}
}
async function deleteOverride(username) {
if (!confirm(`Remove override for "${username}"?`)) return;
rbacMsg = ""; rbacErr = "";
try {
await del(`/auth/rbac/overrides/${encodeURIComponent(username)}`);
rbacMsg = `Removed override for ${username}`;
await loadRbac();
} catch (e) {
rbacErr = e.body?.detail || "Failed to delete";
}
}
onMount(() => {
loadPasskeys();
if (currentUser.role === "admin") loadRbac();
});
</script>
<div class="space-y-6">
@@ -208,6 +280,96 @@
</div>
</div>
{#if currentUser.role === "admin" && rbacConfig}
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-6 shadow-sm">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Access Control</h3>
<button onclick={loadRbac} disabled={rbacLoading} class="px-3 py-1.5 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors disabled:opacity-50">
{rbacLoading ? "Loading..." : "Refresh"}
</button>
</div>
{#if rbacMsg}
<div class="text-sm text-emerald-600 bg-emerald-50 px-3 py-2 rounded-lg mb-4">{rbacMsg}</div>
{/if}
{#if rbacErr}
<div class="text-sm text-rose-600 bg-rose-50 px-3 py-2 rounded-lg mb-4">{rbacErr}</div>
{/if}
<!-- Role Defaults -->
<div class="mb-4">
<p class="text-xs font-medium text-surface-500 mb-2">Role Defaults</p>
<div class="space-y-1.5">
{#each Object.entries(rbacConfig.role_defaults || {}) as [role, cfg]}
<div class="flex items-center gap-2 text-xs">
<span class="font-medium text-surface-700 dark:text-surface-300 w-16">{role}</span>
<span class="text-surface-400">{cfg.pages === "*" ? "All pages" : (cfg.pages || []).join(", ")}</span>
</div>
{/each}
</div>
</div>
<!-- User Overrides -->
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<p class="text-xs font-medium text-surface-500">User Overrides</p>
{#if editingUser !== "__new__"}
<button onclick={startAdd} class="px-2 py-1 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-md transition-colors">Add Override</button>
{/if}
</div>
{#if Object.keys(rbacConfig.user_overrides || {}).length > 0}
<div class="space-y-2">
{#each Object.entries(rbacConfig.user_overrides) as [username, cfg]}
{#if editingUser === username}
<div class="p-3 bg-surface-50 dark:bg-surface-700 rounded-lg space-y-2">
<p class="text-xs font-medium text-surface-700 dark:text-surface-200">{username}</p>
<div class="flex flex-wrap gap-1.5">
{#each allPages as p}
<button onclick={() => togglePage(p)} class="px-2 py-0.5 text-xs rounded-md transition-colors {editPages.includes(p) ? 'bg-primary-600 text-white' : 'bg-surface-200 dark:bg-surface-600 text-surface-500'}">{p}</button>
{/each}
</div>
<div class="flex gap-2">
<button onclick={saveOverride} class="px-3 py-1 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-md">Save</button>
<button onclick={cancelEdit} class="px-3 py-1 text-xs font-medium text-surface-500 bg-surface-200 hover:bg-surface-300 rounded-md">Cancel</button>
</div>
</div>
{:else}
<div class="flex items-center justify-between px-3 py-2 bg-surface-50 dark:bg-surface-700 rounded-lg">
<div>
<span class="text-sm font-medium text-surface-700 dark:text-surface-200">{username}</span>
<span class="text-xs text-surface-400 ml-2">{(cfg.pages || []).join(", ")}</span>
</div>
<div class="flex gap-1.5">
<button onclick={() => startEdit(username, cfg.pages || [])} class="text-xs text-primary-500 hover:text-primary-700">Edit</button>
<button onclick={() => deleteOverride(username)} class="text-xs text-rose-500 hover:text-rose-700">Delete</button>
</div>
</div>
{/if}
{/each}
</div>
{:else if editingUser !== "__new__"}
<p class="text-sm text-surface-400">No user overrides configured</p>
{/if}
{#if editingUser === "__new__"}
<div class="p-3 bg-surface-50 dark:bg-surface-700 rounded-lg space-y-2 mt-2">
<input type="text" bind:value={newUsername} placeholder="Username" class="w-full px-3 py-1.5 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-800 dark:text-white" />
<div class="flex flex-wrap gap-1.5">
{#each allPages as p}
<button onclick={() => togglePage(p)} class="px-2 py-0.5 text-xs rounded-md transition-colors {editPages.includes(p) ? 'bg-primary-600 text-white' : 'bg-surface-200 dark:bg-surface-600 text-surface-500'}">{p}</button>
{/each}
</div>
<div class="flex gap-2">
<button onclick={saveOverride} class="px-3 py-1 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-md">Save</button>
<button onclick={cancelEdit} class="px-3 py-1 text-xs font-medium text-surface-500 bg-surface-200 hover:bg-surface-300 rounded-md">Cancel</button>
</div>
</div>
{/if}
</div>
</div>
{/if}
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-6 shadow-sm">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Audit Log</h3>