Files
nas-tools/dashboard/frontend/src/routes/Settings.svelte
T
Gan, Jimmy 7ab2ad41c4
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 1m37s
feat: add role-based sidebar link visibility control
2026-03-01 18:17:45 +08:00

493 lines
22 KiB
Svelte

<script>
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("");
let confirmPassword = $state("");
let message = $state("");
let error = $state("");
let saving = $state(false);
let passkeys = $state([]);
let passkeyMsg = $state("");
let passkeyErr = $state("");
let passkeyLoading = $state(false);
async function loadPasskeys() {
try {
const res = await get("/auth/passkey/list");
passkeys = res.passkeys;
} catch {}
}
function base64urlToBuffer(b64) {
const s = b64.replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(s);
return Uint8Array.from(raw, c => c.charCodeAt(0)).buffer;
}
function bufferToBase64url(buf) {
const bytes = new Uint8Array(buf);
let s = '';
bytes.forEach(b => s += String.fromCharCode(b));
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
async function registerPasskey() {
passkeyLoading = true;
passkeyMsg = ""; passkeyErr = "";
try {
if (!window.PublicKeyCredential) {
throw new Error("Passkeys are not supported in this browser or context");
}
const opts = await post("/auth/passkey/register/options");
opts.challenge = base64urlToBuffer(opts.challenge);
opts.user.id = base64urlToBuffer(opts.user.id);
if (opts.excludeCredentials) {
opts.excludeCredentials = opts.excludeCredentials.map(c => ({ ...c, id: base64urlToBuffer(c.id) }));
}
const cred = await navigator.credentials.create({ publicKey: opts });
const name = prompt("Name this passkey (e.g. MacBook, iPhone):", "Passkey") || "Passkey";
const body = {
id: cred.id,
rawId: bufferToBase64url(cred.rawId),
type: cred.type,
name,
response: {
attestationObject: bufferToBase64url(cred.response.attestationObject),
clientDataJSON: bufferToBase64url(cred.response.clientDataJSON),
},
};
await post("/auth/passkey/register/verify", body);
passkeyMsg = "Passkey registered successfully";
await loadPasskeys();
} catch (e) {
passkeyErr = e.body?.detail || e.message || "Failed to register passkey";
}
passkeyLoading = false;
}
async function clearPasskeys() {
if (!confirm("Remove all passkeys?")) return;
passkeyLoading = true;
passkeyMsg = ""; passkeyErr = "";
try {
await post("/auth/passkey/clear");
passkeyMsg = "All passkeys removed";
passkeys = [];
} catch (e) {
passkeyErr = e.body?.detail || "Failed to remove passkeys";
}
passkeyLoading = false;
}
async function deletePasskey(id, name) {
if (!confirm(`Remove passkey "${name}"?`)) return;
passkeyLoading = true;
passkeyMsg = ""; passkeyErr = "";
try {
await post("/auth/passkey/delete", { id });
passkeyMsg = `Passkey "${name}" removed`;
await loadPasskeys();
} catch (e) {
passkeyErr = e.body?.detail || "Failed to remove passkey";
}
passkeyLoading = false;
}
async function changePassword() {
error = "";
message = "";
if (newPassword !== confirmPassword) { error = "Passwords do not match"; return; }
if (newPassword.length < 8) { error = "Password must be at least 8 characters"; return; }
saving = true;
try {
await post("/auth/change-password", { current_password: currentPassword, new_password: newPassword });
message = "Password changed successfully";
currentPassword = ""; newPassword = ""; confirmPassword = "";
} catch (e) {
error = e.body?.detail || "Failed to change password";
}
saving = false;
}
let auditEntries = $state([]);
let auditLoading = $state(false);
let auditFilter = $state("all");
const levelColors = { high: "text-rose-400", medium: "text-amber-400", low: "text-emerald-400", info: "text-surface-400" };
const levelLabels = { high: "HIGH", medium: "MED", low: "LOW", info: "INFO" };
let filteredEntries = $derived(auditFilter === "all" ? auditEntries : auditEntries.filter(e => e.level === auditFilter));
async function loadAuditLog() {
auditLoading = true;
try {
const res = await get("/system/audit-log?lines=200");
auditEntries = res.entries.reverse();
} catch {}
auditLoading = false;
}
// Access Control
const allPages = ["dashboard", "docker", "files", "terminal", "security", "openclaw", "chat-digest", "gitea", "settings"];
const allSidebarLinks = ["Overview", "Docker", "Files", "Terminal", "Security", "Navidrome", "Jellyfin", "Audiobookshelf", "Immich", "OpenClaw", "Chat Digest", "Repos", "Gitea Web", "Stirling PDF", "Vaultwarden", "n8n", "Speedtest"];
let rbacConfig = $state(null);
let rbacLoading = $state(false);
let rbacMsg = $state("");
let rbacErr = $state("");
let editingUser = $state("");
let editPages = $state([]);
let newUsername = $state("");
let editingRole = $state("");
let editRolePages = $state([]);
let editRoleSidebarLinks = $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 = "";
editingRole = "";
}
function startAdd() {
editingUser = "__new__";
editPages = ["dashboard"];
newUsername = "";
editingRole = "";
}
function cancelEdit() {
editingUser = "";
editPages = [];
newUsername = "";
editingRole = "";
editRolePages = [];
editRoleSidebarLinks = [];
}
function startEditRole(role, pages, sidebarLinks) {
editingRole = role;
editRolePages = pages === "*" ? ["*"] : [...pages];
editRoleSidebarLinks = sidebarLinks === "*" ? ["*"] : (sidebarLinks ? [...sidebarLinks] : ["*"]);
editingUser = "";
}
function toggleRolePage(p) {
if (p === "*") {
editRolePages = ["*"];
} else {
if (editRolePages.includes("*")) editRolePages = [];
if (editRolePages.includes(p)) editRolePages = editRolePages.filter(x => x !== p);
else editRolePages = [...editRolePages, p];
}
}
function toggleRoleSidebarLink(link) {
if (link === "*") {
editRoleSidebarLinks = ["*"];
} else {
if (editRoleSidebarLinks.includes("*")) editRoleSidebarLinks = [];
if (editRoleSidebarLinks.includes(link)) editRoleSidebarLinks = editRoleSidebarLinks.filter(x => x !== link);
else editRoleSidebarLinks = [...editRoleSidebarLinks, link];
}
}
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";
}
}
async function saveRoleDefault() {
rbacMsg = ""; rbacErr = "";
if (!editingRole) return;
if (!editRolePages.length) { rbacErr = "Select at least one page or '*' for all"; return; }
if (!editRoleSidebarLinks.length) { rbacErr = "Select at least one sidebar link or '*' for all"; return; }
try {
const pages = editRolePages.includes("*") ? "*" : editRolePages;
const sidebar_links = editRoleSidebarLinks.includes("*") ? "*" : editRoleSidebarLinks;
await put(`/auth/rbac/roles/${encodeURIComponent(editingRole)}`, { pages, sidebar_links });
rbacMsg = `Updated role default for ${editingRole}`;
cancelEdit();
await loadRbac();
} catch (e) {
rbacErr = e.body?.detail || "Failed to save role";
}
}
onMount(() => {
loadPasskeys();
if (currentUser.role === "admin") loadRbac();
});
</script>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">Settings</h1>
<p class="text-sm text-surface-400 mt-1">Account and security settings</p>
</div>
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-6 shadow-sm max-w-md">
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-4">Change Password</h3>
{#if message}
<div class="text-sm text-emerald-600 bg-emerald-50 px-3 py-2 rounded-lg mb-4">{message}</div>
{/if}
{#if error}
<div class="text-sm text-rose-600 bg-rose-50 px-3 py-2 rounded-lg mb-4">{error}</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); changePassword(); }} class="space-y-3">
<div>
<label class="block text-xs font-medium text-surface-500 mb-1">Current Password</label>
<input type="password" bind:value={currentPassword} required class="w-full px-3 py-2 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-700 dark:text-white" />
</div>
<div>
<label class="block text-xs font-medium text-surface-500 mb-1">New Password</label>
<input type="password" bind:value={newPassword} required class="w-full px-3 py-2 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-700 dark:text-white" />
</div>
<div>
<label class="block text-xs font-medium text-surface-500 mb-1">Confirm New Password</label>
<input type="password" bind:value={confirmPassword} required class="w-full px-3 py-2 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-700 dark:text-white" />
</div>
<button type="submit" disabled={saving} class="w-full px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-lg transition-colors disabled:opacity-50">
{saving ? "Saving..." : "Change Password"}
</button>
</form>
</div>
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-6 shadow-sm max-w-md">
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-4">Passkeys</h3>
{#if passkeyMsg}
<div class="text-sm text-emerald-600 bg-emerald-50 px-3 py-2 rounded-lg mb-4">{passkeyMsg}</div>
{/if}
{#if passkeyErr}
<div class="text-sm text-rose-600 bg-rose-50 px-3 py-2 rounded-lg mb-4">{passkeyErr}</div>
{/if}
{#if passkeys.length > 0}
<div class="space-y-2 mb-4">
{#each passkeys as pk}
<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">{pk.name}</span>
{#if pk.created_at}
<span class="text-xs text-surface-400 ml-2">{new Date(pk.created_at).toLocaleDateString()}</span>
{/if}
</div>
<button onclick={() => deletePasskey(pk.id, pk.name)} disabled={passkeyLoading} class="text-xs text-rose-500 hover:text-rose-700 disabled:opacity-50">Remove</button>
</div>
{/each}
</div>
{:else}
<p class="text-sm text-surface-400 mb-4">No passkeys registered</p>
{/if}
<div class="flex gap-2">
<button onclick={registerPasskey} disabled={passkeyLoading} class="px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-lg transition-colors disabled:opacity-50">
Register new passkey
</button>
{#if passkeys.length > 1}
<button onclick={clearPasskeys} disabled={passkeyLoading} class="px-4 py-2 text-sm font-medium text-rose-600 bg-rose-50 hover:bg-rose-100 rounded-lg transition-colors disabled:opacity-50">
Remove all
</button>
{/if}
</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-2">
{#each Object.entries(rbacConfig.role_defaults || {}) as [role, cfg]}
{#if editingRole === role}
<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">{role}</p>
<p class="text-[10px] text-surface-500 mt-1">Pages (internal dashboard pages)</p>
<div class="flex flex-wrap gap-1.5">
<button onclick={() => toggleRolePage("*")} class="px-2 py-0.5 text-xs rounded-md transition-colors {editRolePages.includes('*') ? 'bg-primary-600 text-white' : 'bg-surface-200 dark:bg-surface-600 text-surface-500'}">* (all)</button>
{#each allPages as p}
<button onclick={() => toggleRolePage(p)} class="px-2 py-0.5 text-xs rounded-md transition-colors {editRolePages.includes(p) ? 'bg-primary-600 text-white' : 'bg-surface-200 dark:bg-surface-600 text-surface-500'}">{p}</button>
{/each}
</div>
<p class="text-[10px] text-surface-500 mt-2">Sidebar Links (all services)</p>
<div class="flex flex-wrap gap-1.5">
<button onclick={() => toggleRoleSidebarLink("*")} class="px-2 py-0.5 text-xs rounded-md transition-colors {editRoleSidebarLinks.includes('*') ? 'bg-primary-600 text-white' : 'bg-surface-200 dark:bg-surface-600 text-surface-500'}">* (all)</button>
{#each allSidebarLinks as link}
<button onclick={() => toggleRoleSidebarLink(link)} class="px-2 py-0.5 text-xs rounded-md transition-colors {editRoleSidebarLinks.includes(link) ? 'bg-primary-600 text-white' : 'bg-surface-200 dark:bg-surface-600 text-surface-500'}">{link}</button>
{/each}
</div>
<div class="flex gap-2">
<button onclick={saveRoleDefault} class="px-3 py-1 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-md transition-colors">Save</button>
<button onclick={cancelEdit} class="px-3 py-1 text-xs font-medium text-surface-600 hover:text-surface-800 dark:text-surface-400 dark:hover:text-surface-200">Cancel</button>
</div>
</div>
{:else}
<div class="flex items-center justify-between p-2 bg-surface-50 dark:bg-surface-700 rounded-lg">
<div class="flex flex-col gap-1 text-xs">
<div class="flex items-center gap-2">
<span class="font-medium text-surface-700 dark:text-surface-300 w-16">{role}</span>
<span class="text-surface-400">Pages: {cfg.pages === "*" ? "All" : (cfg.pages || []).join(", ")}</span>
</div>
<div class="flex items-center gap-2">
<span class="w-16"></span>
<span class="text-surface-400">Links: {cfg.sidebar_links === "*" ? "All" : (cfg.sidebar_links || ["*"]).join(", ")}</span>
</div>
</div>
<button onclick={() => startEditRole(role, cfg.pages, cfg.sidebar_links)} class="text-xs text-primary-500 hover:text-primary-700">Edit</button>
</div>
{/if}
{/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>
<button onclick={loadAuditLog} disabled={auditLoading} 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">
{auditLoading ? "Loading..." : auditEntries.length ? "Refresh" : "Load"}
</button>
</div>
{#if auditEntries.length > 0}
<div class="flex gap-1.5 mb-3">
{#each ["all", "high", "medium", "low", "info"] as lvl}
<button onclick={() => auditFilter = lvl} class="px-2.5 py-1 text-xs rounded-md transition-colors {auditFilter === lvl ? 'bg-surface-800 text-white' : 'bg-surface-100 text-surface-500 hover:bg-surface-200'}">
{lvl === "all" ? "All" : levelLabels[lvl] || lvl}
{#if lvl !== "all"}
<span class="ml-1 opacity-60">{auditEntries.filter(e => e.level === lvl).length}</span>
{/if}
</button>
{/each}
</div>
<div class="max-h-80 overflow-auto rounded-lg bg-surface-950 p-3 space-y-0.5">
{#each filteredEntries as e}
<div class="text-xs font-mono flex gap-2 leading-5">
<span class="{levelColors[e.level]} w-8 shrink-0">{levelLabels[e.level]}</span>
<span class="text-surface-500 shrink-0">{e.ts.replace('T', ' ')}</span>
<span class="text-surface-400 shrink-0 w-10">{e.status}</span>
<span class="text-surface-300 shrink-0">{e.user}</span>
<span class="text-surface-500">{e.method} {e.path}</span>
</div>
{/each}
</div>
{:else if !auditLoading}
<p class="text-sm text-surface-400">Click Load to view recent audit log entries</p>
{/if}
</div>
</div>