463 lines
20 KiB
Svelte
463 lines
20 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"];
|
|
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([]);
|
|
|
|
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 = [];
|
|
}
|
|
|
|
function startEditRole(role, pages) {
|
|
editingRole = role;
|
|
editRolePages = pages === "*" ? ["*"] : [...pages];
|
|
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 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; }
|
|
try {
|
|
const pages = editRolePages.includes("*") ? "*" : editRolePages;
|
|
await put(`/auth/rbac/roles/${encodeURIComponent(editingRole)}`, { pages });
|
|
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>
|
|
<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>
|
|
<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 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>
|
|
<button onclick={() => startEditRole(role, cfg.pages)} 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>
|