Give local users their own manager: custom passwords and role reassignment

Move user management out of Settings into its own /users page (nav-gated
to owners), let an owner type a specific password on reset instead of
always generating a random one, and add role reassignment via a new
PUT /auth/users/{id}/role endpoint. Role changes revoke the target's
existing sessions, same as a password reset, so a demoted user can't
keep acting under a stale, higher-privileged session.
This commit is contained in:
2026-08-21 14:23:52 -07:00
parent 4b5dae5879
commit 864e68253a
10 changed files with 887 additions and 225 deletions
+8
View File
@@ -474,6 +474,14 @@ export function resetPassword(id: string, newPassword?: string): Promise<{ passw
});
}
export function setUserRole(id: string, role: string): Promise<LocalUser> {
return request(`/auth/users/${id}/role`, {
method: 'PUT',
credentials: 'include',
body: JSON.stringify({ role })
});
}
// --- alerting ---------------------------------------------------------
export type ConditionType = 'threshold' | 'absence';
+16 -3
View File
@@ -18,15 +18,16 @@
onCloseMobile
}: { onOpenPalette: () => void; mobileOpen?: boolean; onCloseMobile?: () => void } = $props();
const navItems = [
const baseNavItems = [
{ href: '/', label: 'Search', icon: '◇' },
{ href: '/dashboards', label: 'Dashboards', icon: '▤' },
{ href: '/alerts', label: 'Alerts', icon: '▲' },
{ href: '/data-sources', label: 'Data Sources', icon: '◈' },
{ href: '/agents', label: 'Agents', icon: '●' },
{ href: '/hosts', label: 'Hosts', icon: '▣' },
{ href: '/settings', label: 'Settings', icon: '⚙' }
{ href: '/hosts', label: 'Hosts', icon: '▣' }
];
const usersNavItem = { href: '/users', label: 'Users', icon: '◐' };
const settingsNavItem = { href: '/settings', label: 'Settings', icon: '⚙' };
function isActive(href: string): boolean {
if (href === '/') return page.url.pathname === '/';
@@ -44,6 +45,18 @@
getLocalSession().then((s) => (localSession = s === 'disabled' ? null : s));
});
// The Users nav item only ever makes sense for local-auth mode's
// owner-only user manager (see routes/users/+page.svelte) -- an
// enterprise-SSO deployment or a non-owner local session never sees
// it, same gating that page enforces itself if reached directly.
const isLocalOwner = $derived.by(() => {
const s = localSession;
return s !== null && s.role === 'owner';
});
const navItems = $derived(
isLocalOwner ? [...baseNavItems, usersNavItem, settingsNavItem] : [...baseNavItems, settingsNavItem]
);
let loggingOut = $state(false);
async function handleLogout() {
loggingOut = true;
+7 -222
View File
@@ -1,17 +1,5 @@
<script lang="ts">
import {
getAuthFeatures,
enterpriseAuthBase,
localAuthEnabled,
getLocalSession,
listUsers,
createUser,
deleteUser,
resetPassword,
type AuthFeatures,
type LocalSession,
type LocalUser
} from '$lib/api';
import { getAuthFeatures, enterpriseAuthBase, localAuthEnabled, type AuthFeatures } from '$lib/api';
import { getTheme, setTheme, type Theme } from '$lib/theme.svelte';
import { getDensity, setDensity, type Density } from '$lib/density.svelte';
@@ -25,75 +13,6 @@
}
load();
// --- local user management (owner-role only, see api/localauth) ---
let localSession = $state<LocalSession | 'disabled' | null>(null);
let users = $state<LocalUser[]>([]);
let usersLoading = $state(false);
let usersError = $state('');
let newUsername = $state('');
let newPassword = $state('');
let newRole = $state('editor');
let creating = $state(false);
// lastReset holds a just-generated password so it can be shown once
// (never stored, never recoverable after -- same posture
// -seed-admin's initial password takes, see cmd/api/main.go).
let lastReset = $state<{ userId: string; password: string } | null>(null);
async function loadUsers() {
if (!localAuthEnabled) return;
localSession = await getLocalSession();
if (localSession === 'disabled' || localSession === null || localSession.role !== 'owner') return;
usersLoading = true;
usersError = '';
try {
users = await listUsers();
} catch (e) {
usersError = e instanceof Error ? e.message : String(e);
} finally {
usersLoading = false;
}
}
loadUsers();
async function handleCreate(e: SubmitEvent) {
e.preventDefault();
if (creating) return;
creating = true;
usersError = '';
try {
await createUser(newUsername, newPassword, newRole);
newUsername = '';
newPassword = '';
newRole = 'editor';
await loadUsers();
} catch (e) {
usersError = e instanceof Error ? e.message : String(e);
} finally {
creating = false;
}
}
async function handleDelete(id: string) {
usersError = '';
try {
await deleteUser(id);
await loadUsers();
} catch (e) {
usersError = e instanceof Error ? e.message : String(e);
}
}
async function handleReset(id: string) {
usersError = '';
lastReset = null;
try {
const { password } = await resetPassword(id);
if (password) lastReset = { userId: id, password };
} catch (e) {
usersError = e instanceof Error ? e.message : String(e);
}
}
const themeOptions: { value: Theme; label: string; hint: string }[] = [
{ value: 'dark', label: 'Dark', hint: 'Default' },
{ value: 'light', label: 'Light', hint: '' },
@@ -143,71 +62,11 @@
<section>
<h2>Core</h2>
<p>Single-tenant deployment settings live here. Nothing configurable yet.</p>
{#if localAuthEnabled}
<p class="note">Manage accounts, passwords, and roles from <a href="/users">Users</a>.</p>
{/if}
</section>
{#if localAuthEnabled && localSession && localSession !== 'disabled'}
<section>
<h2>Users</h2>
{#if localSession.role !== 'owner'}
<p class="note">Only an owner can manage users. Signed in as {localSession.username} ({localSession.role}).</p>
{:else}
{#if usersError}<p class="error">{usersError}</p>{/if}
{#if usersLoading}
<p class="muted">Loading…</p>
{:else}
<table>
<thead>
<tr>
<th>Username</th>
<th>Role</th>
<th></th>
</tr>
</thead>
<tbody>
{#each users as u (u.id)}
<tr>
<td>{u.username}</td>
<td class="role-cell">{u.role}</td>
<td class="actions">
<button type="button" onclick={() => handleReset(u.id)}>Reset password</button>
<button type="button" class="danger" onclick={() => handleDelete(u.id)}>Delete</button>
</td>
</tr>
{#if lastReset?.userId === u.id}
<tr>
<td colspan="3">
<p class="note">
New password (shown once): <code>{lastReset.password}</code>
</p>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
{/if}
<form onsubmit={handleCreate} class="create-user">
<input type="text" placeholder="Username" bind:value={newUsername} disabled={creating} required />
<input
type="password"
placeholder="Password (min. 8 characters)"
bind:value={newPassword}
disabled={creating}
required
/>
<select bind:value={newRole} disabled={creating}>
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
<option value="owner">Owner</option>
</select>
<button type="submit" disabled={creating}>{creating ? 'Adding…' : 'Add user'}</button>
</form>
{/if}
</section>
{/if}
{#if loading}
<p class="muted">Loading…</p>
{:else if features.sso_configured}
@@ -256,6 +115,9 @@
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.note a {
color: var(--color-accent);
}
.option-group {
display: flex;
gap: var(--space-2);
@@ -298,81 +160,4 @@
.option.selected .option-hint {
color: var(--color-text);
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: var(--space-4);
font-size: var(--text-sm);
}
th,
td {
text-align: left;
padding: var(--space-2) var(--space-2);
border-bottom: 1px solid var(--color-border);
}
.role-cell {
color: var(--color-text-muted);
text-transform: capitalize;
}
.actions {
display: flex;
gap: var(--space-2);
justify-content: flex-end;
}
.actions button {
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
cursor: pointer;
}
.actions button.danger {
color: var(--color-danger);
border-color: var(--color-danger);
}
.create-user {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
align-items: center;
}
.create-user input,
.create-user select {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: var(--space-2) var(--space-3);
}
.create-user button {
padding: var(--space-2) var(--space-4);
font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
color: var(--color-bg);
background: var(--color-accent);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
}
.create-user button:disabled {
cursor: default;
opacity: 0.6;
}
.error {
color: var(--color-danger);
font-size: var(--text-sm);
}
code {
font-family: var(--font-mono);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 3px;
padding: 0.1rem 0.4rem;
}
</style>
+542
View File
@@ -0,0 +1,542 @@
<script lang="ts">
import {
localAuthEnabled,
getLocalSession,
listUsers,
createUser,
deleteUser,
resetPassword,
setUserRole,
type LocalSession,
type LocalUser
} from '$lib/api';
const roleOptions = ['viewer', 'editor', 'admin', 'owner'] as const;
let localSession = $state<LocalSession | 'disabled' | null>(null);
let checked = $state(false);
let users = $state<LocalUser[]>([]);
let usersLoading = $state(false);
let usersError = $state('');
async function loadUsers() {
localSession = localAuthEnabled ? await getLocalSession() : 'disabled';
checked = true;
if (localSession === 'disabled' || localSession === null || localSession.role !== 'owner') return;
usersLoading = true;
usersError = '';
try {
users = await listUsers();
} catch (e) {
usersError = e instanceof Error ? e.message : String(e);
} finally {
usersLoading = false;
}
}
loadUsers();
// --- create user ---
let newUsername = $state('');
let newPassword = $state('');
let newRole = $state('editor');
let creating = $state(false);
let showCreate = $state(false);
async function handleCreate(e: SubmitEvent) {
e.preventDefault();
if (creating) return;
creating = true;
usersError = '';
try {
await createUser(newUsername, newPassword, newRole);
newUsername = '';
newPassword = '';
newRole = 'editor';
showCreate = false;
await loadUsers();
} catch (e) {
usersError = e instanceof Error ? e.message : String(e);
} finally {
creating = false;
}
}
async function handleDelete(u: LocalUser) {
if (!confirm(`Delete ${u.username}? This can't be undone.`)) return;
usersError = '';
try {
await deleteUser(u.id);
if (passwordTarget === u.id) passwordTarget = null;
await loadUsers();
} catch (e) {
usersError = e instanceof Error ? e.message : String(e);
}
}
// --- role reassignment: saves immediately when the select changes,
// reverting on failure so the UI never shows a role that didn't
// actually take (see api/localauth's SetRole doc comment -- a role
// change also revokes the target's existing sessions). ---
let roleSaving = $state<string | null>(null);
async function handleRoleChange(u: LocalUser, role: string) {
if (role === u.role) return;
const previous = u.role;
u.role = role;
roleSaving = u.id;
usersError = '';
try {
await setUserRole(u.id, role);
} catch (e) {
u.role = previous;
usersError = e instanceof Error ? e.message : String(e);
} finally {
roleSaving = null;
}
}
// --- password management: an admin can type the new password
// directly (the default) instead of always getting a random one
// back -- generating one is still one click away for anyone who
// wants that instead. ---
let passwordTarget = $state<string | null>(null);
let passwordInput = $state('');
let passwordBusy = $state(false);
let passwordShown = $state<{ userId: string; password: string } | null>(null);
function togglePasswordPanel(id: string) {
passwordTarget = passwordTarget === id ? null : id;
passwordInput = '';
passwordShown = null;
usersError = '';
}
async function handleSetPassword(id: string) {
if (passwordInput.length < 8) {
usersError = 'Password must be at least 8 characters';
return;
}
passwordBusy = true;
usersError = '';
try {
await resetPassword(id, passwordInput);
passwordTarget = null;
passwordInput = '';
} catch (e) {
usersError = e instanceof Error ? e.message : String(e);
} finally {
passwordBusy = false;
}
}
async function handleGeneratePassword(id: string) {
passwordBusy = true;
usersError = '';
try {
const { password } = await resetPassword(id);
if (password) passwordShown = { userId: id, password };
} catch (e) {
usersError = e instanceof Error ? e.message : String(e);
} finally {
passwordBusy = false;
}
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
</script>
<main>
<h1>Users</h1>
{#if !checked}
<p class="muted">Loading…</p>
{:else if localSession === 'disabled'}
<p class="note">
This deployment doesn't have local user accounts enabled -- see the "Single sign-on" section on
<a href="/settings">Settings</a> if it's using enterprise SSO instead.
</p>
{:else if localSession === null}
<p class="note">Sign in to manage users.</p>
{:else if localSession.role !== 'owner'}
<p class="note">Only an owner can manage users. Signed in as {localSession.username} ({localSession.role}).</p>
{:else}
<p class="subtitle">Local accounts for this deployment: passwords, and roles.</p>
{#if usersError}<p class="error">{usersError}</p>{/if}
{#if usersLoading}
<p class="muted">Loading…</p>
{:else}
<table>
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
{#each users as u (u.id)}
<tr>
<td>
<div class="user-cell">
<span class="avatar" aria-hidden="true">{u.username.slice(0, 1).toUpperCase()}</span>
<span class="username">{u.username}</span>
{#if u.id === localSession.user_id}<span class="you">you</span>{/if}
</div>
</td>
<td>
<select
class="role-select"
value={u.role}
disabled={roleSaving === u.id}
onchange={(e) => handleRoleChange(u, e.currentTarget.value)}
aria-label="Role for {u.username}"
>
{#each roleOptions as r (r)}
<option value={r}>{r}</option>
{/each}
</select>
</td>
<td class="muted">{formatDate(u.created_at)}</td>
<td class="actions">
<button type="button" onclick={() => togglePasswordPanel(u.id)}>Change password</button>
<button type="button" class="danger" onclick={() => handleDelete(u)}>Delete</button>
</td>
</tr>
{#if passwordTarget === u.id}
<tr class="password-row">
<td colspan="4">
<div class="password-panel">
{#if passwordShown?.userId === u.id}
<p class="note">
New password (shown once, save it now): <code>{passwordShown.password}</code>
</p>
{:else}
<label for="pw-{u.id}">New password for {u.username}</label>
<div class="password-row-inner">
<input
id="pw-{u.id}"
type="text"
placeholder="Type a new password (min. 8 characters)"
bind:value={passwordInput}
disabled={passwordBusy}
/>
<button
type="button"
disabled={passwordBusy}
onclick={() => handleSetPassword(u.id)}
>
{passwordBusy ? 'Saving…' : 'Save password'}
</button>
</div>
<button
type="button"
class="link"
disabled={passwordBusy}
onclick={() => handleGeneratePassword(u.id)}
>
Generate a random password instead
</button>
{/if}
</div>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
{/if}
{#if showCreate}
<form onsubmit={handleCreate} class="create-user">
<div class="field">
<label for="new-username">Username</label>
<input id="new-username" type="text" bind:value={newUsername} disabled={creating} required />
</div>
<div class="field">
<label for="new-password">Password</label>
<input
id="new-password"
type="text"
placeholder="Min. 8 characters"
bind:value={newPassword}
disabled={creating}
required
/>
</div>
<div class="field">
<label for="new-role">Role</label>
<select id="new-role" bind:value={newRole} disabled={creating}>
{#each roleOptions as r (r)}
<option value={r}>{r}</option>
{/each}
</select>
</div>
<div class="field-actions">
<button type="submit" disabled={creating}>{creating ? 'Adding…' : 'Add user'}</button>
<button type="button" class="link" onclick={() => (showCreate = false)} disabled={creating}
>Cancel</button
>
</div>
</form>
{:else}
<button type="button" class="add-user-btn" onclick={() => (showCreate = true)}>+ Add user</button>
{/if}
{/if}
</main>
<style>
main {
max-width: 44rem;
}
h1 {
font-size: var(--text-xl);
margin-bottom: var(--space-2);
}
.subtitle {
color: var(--color-text-muted);
font-size: var(--text-sm);
margin-bottom: var(--space-5);
}
.muted {
color: var(--color-text-muted);
}
.note {
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.note a {
color: var(--color-accent);
}
.error {
color: var(--color-danger);
font-size: var(--text-sm);
margin-bottom: var(--space-3);
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: var(--space-4);
font-size: var(--text-sm);
}
th,
td {
text-align: left;
padding: var(--space-3) var(--space-2);
border-bottom: 1px solid var(--color-border);
vertical-align: middle;
}
th {
color: var(--color-text-muted);
font-weight: var(--font-weight-medium);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.02em;
}
.user-cell {
display: flex;
align-items: center;
gap: var(--space-2);
}
.avatar {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
flex: none;
border-radius: 50%;
background: color-mix(in srgb, var(--color-accent) 16%, var(--color-surface));
color: var(--color-text);
font-size: var(--text-xs);
font-weight: var(--font-weight-medium);
}
.username {
font-weight: var(--font-weight-medium);
color: var(--color-text);
}
.you {
font-size: var(--text-xs);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.05rem 0.35rem;
}
.role-select {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: var(--space-1) var(--space-2);
text-transform: capitalize;
cursor: pointer;
}
.role-select:disabled {
cursor: default;
opacity: 0.6;
}
.actions {
display: flex;
gap: var(--space-2);
justify-content: flex-end;
white-space: nowrap;
}
.actions button {
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
cursor: pointer;
}
.actions button.danger {
color: var(--color-danger);
border-color: var(--color-danger);
}
.password-row td {
border-bottom: 1px solid var(--color-border);
padding-top: 0;
}
.password-panel {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.password-panel label {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.password-row-inner {
display: flex;
gap: var(--space-2);
}
.password-row-inner input {
flex: 1;
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--color-text);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: var(--space-2) var(--space-3);
}
.password-row-inner button {
padding: var(--space-2) var(--space-4);
font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
color: var(--color-bg);
background: var(--color-accent);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
white-space: nowrap;
}
.password-row-inner button:disabled {
cursor: default;
opacity: 0.6;
}
code {
font-family: var(--font-mono);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 3px;
padding: 0.1rem 0.4rem;
}
.link {
align-self: flex-start;
background: none;
border: none;
padding: 0;
color: var(--color-accent);
font-family: var(--font-ui);
font-size: var(--text-xs);
cursor: pointer;
}
.link:disabled {
cursor: default;
opacity: 0.6;
}
.add-user-btn {
padding: var(--space-2) var(--space-4);
font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.add-user-btn:hover {
border-color: var(--color-border-strong);
}
.create-user {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--space-3);
padding: var(--space-4);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.field {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.field label {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.field input,
.field select {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--color-text);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: var(--space-2) var(--space-3);
}
.field-actions {
display: flex;
align-items: center;
gap: var(--space-3);
}
.field-actions button[type='submit'] {
padding: var(--space-2) var(--space-4);
font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
color: var(--color-bg);
background: var(--color-accent);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
}
.field-actions button[type='submit']:disabled {
cursor: default;
opacity: 0.6;
}
</style>
+3
View File
@@ -0,0 +1,3 @@
// Same shape as settings/+page.ts: no route params, data comes from a
// client-side fetch.
export const prerender = true;