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
+14
View File
@@ -100,6 +100,20 @@ func (f *fakeStore) SetPasswordHash(_ context.Context, userID, hash string) erro
return nil
}
func (f *fakeStore) SetRole(_ context.Context, userID string, role authz.Role) error {
u, ok := f.users[userID]
if !ok {
return ErrNotFound
}
u.Role = role
for h, sess := range f.sessions {
if sess.UserID == userID {
delete(f.sessions, h)
}
}
return nil
}
func (f *fakeStore) CountLocalUsers(_ context.Context) (int, error) {
return len(f.users), nil
}
+34
View File
@@ -23,6 +23,7 @@ type store interface {
GetUserByID(ctx context.Context, id string) (*User, error)
DeleteUser(ctx context.Context, id string) error
SetPasswordHash(ctx context.Context, userID, hash string) error
SetRole(ctx context.Context, userID string, role authz.Role) error
CreateSession(ctx context.Context, userID, tenantID string, role authz.Role, ttl time.Duration) (string, error)
DeleteSessionByHash(ctx context.Context, tokenHash string) error
}
@@ -93,6 +94,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /auth/users", authz.RequireRole(h.authorizer, authz.RoleOwner, h.handleCreateUser))
mux.HandleFunc("DELETE /auth/users/{id}", authz.RequireRole(h.authorizer, authz.RoleOwner, h.handleDeleteUser))
mux.HandleFunc("POST /auth/users/{id}/reset-password", authz.RequireRole(h.authorizer, authz.RoleOwner, h.handleResetPassword))
mux.HandleFunc("PUT /auth/users/{id}/role", authz.RequireRole(h.authorizer, authz.RoleOwner, h.handleSetRole))
}
type loginRequest struct {
@@ -284,6 +286,38 @@ func (h *Handler) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
type setRoleRequest struct {
Role string `json:"role"`
}
// handleSetRole deliberately does not stop an owner from demoting or
// re-promoting their own account -- same "single-operator prototype
// deployment knows what it's doing" trust level handleDeleteUser's doc
// comment already establishes for this package's owner-only endpoints.
func (h *Handler) handleSetRole(w http.ResponseWriter, r *http.Request) {
var req setRoleRequest
if !decodeJSON(w, r, &req) {
return
}
role := authz.Role(req.Role)
if !validRole(role) {
writeError(w, http.StatusBadRequest, `role must be "viewer", "editor", "admin", or "owner"`)
return
}
if err := h.store.SetRole(r.Context(), r.PathValue("id"), role); err != nil {
h.writeStoreErr(w, err, "updating role")
return
}
user, err := h.store.GetUserByID(r.Context(), r.PathValue("id"))
if err != nil {
h.writeStoreErr(w, err, "fetching updated user")
return
}
writeJSON(w, http.StatusOK, userResponse{ID: user.ID, Username: user.Username, Role: string(user.Role), CreatedAt: user.CreatedAt})
}
type resetPasswordRequest struct {
// Password is optional -- omitted, a random one is generated and
// returned in the response body exactly once, same "shown once,
+168
View File
@@ -259,3 +259,171 @@ func TestResetPasswordRevokesExistingSessions(t *testing.T) {
t.Fatalf("bob's pre-reset session status = %d, want 401 (reset must revoke existing sessions)", stale.Code)
}
}
func TestResetPasswordAcceptsCallerSuppliedPassword(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "admin", "adminpass1", authz.RoleOwner)
bob := mustCreateUser(t, fs, "bob", "bobspassword", authz.RoleViewer)
_, mux := newTestHandler(t, fs)
adminLogin := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"admin","password":"adminpass1"}`, nil)
adminCookie := sessionCookieFrom(adminLogin)
reset := doRequest(t, mux, http.MethodPost, "/auth/users/"+bob.ID+"/reset-password", `{"password":"bobs-new-password"}`, adminCookie)
if reset.Code != http.StatusOK {
t.Fatalf("reset status = %d, want 200; body=%s", reset.Code, reset.Body.String())
}
var resp resetPasswordResponse
if err := json.Unmarshal(reset.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if resp.Password != "" {
t.Errorf("expected no password echoed back when the caller supplied one, got %q", resp.Password)
}
login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"bob","password":"bobs-new-password"}`, nil)
if login.Code != http.StatusOK {
t.Fatalf("login with caller-supplied password: status = %d, want 200", login.Code)
}
}
func TestOwnerCanReassignRole(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "admin", "adminpass1", authz.RoleOwner)
bob := mustCreateUser(t, fs, "bob", "bobspassword", authz.RoleViewer)
_, mux := newTestHandler(t, fs)
adminLogin := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"admin","password":"adminpass1"}`, nil)
adminCookie := sessionCookieFrom(adminLogin)
bobLogin := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"bob","password":"bobspassword"}`, nil)
bobCookie := sessionCookieFrom(bobLogin)
set := doRequest(t, mux, http.MethodPut, "/auth/users/"+bob.ID+"/role", `{"role":"admin"}`, adminCookie)
if set.Code != http.StatusOK {
t.Fatalf("set role status = %d, want 200; body=%s", set.Code, set.Body.String())
}
var updated userResponse
if err := json.Unmarshal(set.Body.Bytes(), &updated); err != nil {
t.Fatalf("decoding response: %v", err)
}
if updated.Role != "admin" {
t.Errorf("role = %q, want admin", updated.Role)
}
stale := doRequest(t, mux, http.MethodGet, "/auth/session", "", bobCookie)
if stale.Code != http.StatusUnauthorized {
t.Fatalf("bob's pre-reassignment session status = %d, want 401 (role change must revoke existing sessions)", stale.Code)
}
}
// TestOwnerCanReassignEveryRoleTransition exercises every ordered pair
// of the four roles (viewer/editor/admin/owner), including a role's
// no-op transition to itself -- "an owner can reassign a role" must
// hold universally, not just for the one viewer->admin pair
// TestOwnerCanReassignRole already covers, and in particular must not
// silently special-case promotion to/from owner.
func TestOwnerCanReassignEveryRoleTransition(t *testing.T) {
allRoles := []authz.Role{authz.RoleViewer, authz.RoleEditor, authz.RoleAdmin, authz.RoleOwner}
for _, from := range allRoles {
for _, to := range allRoles {
t.Run(string(from)+"_to_"+string(to), func(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "admin", "adminpass1", authz.RoleOwner)
target := mustCreateUser(t, fs, "target", "targetspassword", from)
_, mux := newTestHandler(t, fs)
adminLogin := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"admin","password":"adminpass1"}`, nil)
adminCookie := sessionCookieFrom(adminLogin)
set := doRequest(t, mux, http.MethodPut, "/auth/users/"+target.ID+"/role", `{"role":"`+string(to)+`"}`, adminCookie)
if set.Code != http.StatusOK {
t.Fatalf("set role %s -> %s: status = %d, want 200; body=%s", from, to, set.Code, set.Body.String())
}
var updated userResponse
if err := json.Unmarshal(set.Body.Bytes(), &updated); err != nil {
t.Fatalf("decoding response: %v", err)
}
if updated.Role != string(to) {
t.Fatalf("role in response = %q, want %q", updated.Role, to)
}
// Confirm it actually took, not just that the handler said
// so -- log back in as target and check the session's role.
login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"target","password":"targetspassword"}`, nil)
if login.Code != http.StatusOK {
t.Fatalf("login as target after reassignment: status = %d", login.Code)
}
var loginResp sessionResponse
if err := json.Unmarshal(login.Body.Bytes(), &loginResp); err != nil {
t.Fatalf("decoding login response: %v", err)
}
if loginResp.Role != string(to) {
t.Fatalf("role after fresh login = %q, want %q", loginResp.Role, to)
}
})
}
}
}
// TestOwnerCanReassignOwnRole confirms self-reassignment isn't
// special-cased away -- consistent with handleDeleteUser's documented
// "single-operator deployment knows what it's doing" trust posture, an
// owner can demote (or re-promote) themselves same as anyone else.
func TestOwnerCanReassignOwnRole(t *testing.T) {
fs := newFakeStore()
admin := mustCreateUser(t, fs, "admin", "adminpass1", authz.RoleOwner)
_, mux := newTestHandler(t, fs)
login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"admin","password":"adminpass1"}`, nil)
cookie := sessionCookieFrom(login)
set := doRequest(t, mux, http.MethodPut, "/auth/users/"+admin.ID+"/role", `{"role":"viewer"}`, cookie)
if set.Code != http.StatusOK {
t.Fatalf("self role change status = %d, want 200; body=%s", set.Code, set.Body.String())
}
var updated userResponse
if err := json.Unmarshal(set.Body.Bytes(), &updated); err != nil {
t.Fatalf("decoding response: %v", err)
}
if updated.Role != "viewer" {
t.Errorf("role = %q, want viewer", updated.Role)
}
// The role change revokes sessions same as any other target -- the
// admin's own now-stale cookie must stop working too.
stale := doRequest(t, mux, http.MethodGet, "/auth/session", "", cookie)
if stale.Code != http.StatusUnauthorized {
t.Fatalf("own session after self-reassignment status = %d, want 401", stale.Code)
}
}
func TestSetRoleRejectsInvalidRole(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "admin", "adminpass1", authz.RoleOwner)
bob := mustCreateUser(t, fs, "bob", "bobspassword", authz.RoleViewer)
_, mux := newTestHandler(t, fs)
adminLogin := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"admin","password":"adminpass1"}`, nil)
adminCookie := sessionCookieFrom(adminLogin)
rec := doRequest(t, mux, http.MethodPut, "/auth/users/"+bob.ID+"/role", `{"role":"superuser"}`, adminCookie)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 for an invalid role", rec.Code)
}
}
func TestNonOwnerCannotReassignRole(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor)
bob := mustCreateUser(t, fs, "bob", "bobspassword", authz.RoleViewer)
_, mux := newTestHandler(t, fs)
login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"hunter22"}`, nil)
cookie := sessionCookieFrom(login)
rec := doRequest(t, mux, http.MethodPut, "/auth/users/"+bob.ID+"/role", `{"role":"admin"}`, cookie)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 for a non-owner reassigning a role", rec.Code)
}
}
+29
View File
@@ -229,6 +229,35 @@ func (s *Store) SetPasswordHash(ctx context.Context, userID, hash string) error
return tx.Commit(ctx)
}
// SetRole also revokes every existing session for userID, in the same
// transaction -- Session.Role is a snapshot taken at login (see
// SetPasswordHash's doc comment above for why), so without this a
// demoted user would keep acting under their old, higher-privileged
// role for the rest of an already-issued session's lifetime.
func (s *Store) SetRole(ctx context.Context, userID string, role authz.Role) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
tag, err := tx.Exec(ctx, `
UPDATE tenant_memberships SET role = $1
WHERE user_id = $2 AND tenant_id = $3
AND EXISTS (SELECT 1 FROM users WHERE id = $2 AND username IS NOT NULL)`,
string(role), userID, defaultTenantID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
if _, err := tx.Exec(ctx, `DELETE FROM local_sessions WHERE user_id = $1`, userID); err != nil {
return err
}
return tx.Commit(ctx)
}
// CountLocalUsers backs -seed-admin's idempotency check (see
// cmd/api/main.go's runSeedAdmin): a deployment that already has at
// least one local user never gets a second auto-created admin account.
+66
View File
@@ -155,3 +155,69 @@ func TestIntegrationSetPasswordHashRevokesSessions(t *testing.T) {
t.Errorf("GetSession after password reset: err = %v, want ErrNotFound (reset must revoke existing sessions)", err)
}
}
func TestIntegrationSetRoleRevokesSessions(t *testing.T) {
store := integrationStore(t)
ctx := context.Background()
username := testUsername(t)
hash, _ := HashPassword("password1")
user, err := store.CreateUser(ctx, username, hash, authz.RoleViewer)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
t.Cleanup(func() { _ = store.DeleteUser(ctx, user.ID) })
raw, err := store.CreateSession(ctx, user.ID, "default", authz.RoleViewer, time.Hour)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
if err := store.SetRole(ctx, user.ID, authz.RoleAdmin); err != nil {
t.Fatalf("SetRole: %v", err)
}
got, _, err := store.GetUserForLogin(ctx, username)
if err != nil {
t.Fatalf("GetUserForLogin: %v", err)
}
if got.Role != authz.RoleAdmin {
t.Errorf("role after SetRole = %q, want admin", got.Role)
}
if _, err := store.GetSession(ctx, hashToken(raw)); !errors.Is(err, ErrNotFound) {
t.Errorf("GetSession after role change: err = %v, want ErrNotFound (role change must revoke existing sessions)", err)
}
}
// TestIntegrationSetRoleAcceptsEveryRole confirms tenant_memberships'
// role CHECK constraint (0020_create_tenant_memberships.sql) accepts
// all four roles via SetRole's UPDATE, not just CreateUser's INSERT --
// the handler-level fake-store test already covers all sixteen ordered
// transitions, but only a real Postgres run proves the constraint
// itself doesn't reject any of them.
func TestIntegrationSetRoleAcceptsEveryRole(t *testing.T) {
store := integrationStore(t)
ctx := context.Background()
username := testUsername(t)
hash, _ := HashPassword("password1")
user, err := store.CreateUser(ctx, username, hash, authz.RoleViewer)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
t.Cleanup(func() { _ = store.DeleteUser(ctx, user.ID) })
for _, role := range []authz.Role{authz.RoleEditor, authz.RoleAdmin, authz.RoleOwner, authz.RoleViewer} {
if err := store.SetRole(ctx, user.ID, role); err != nil {
t.Fatalf("SetRole(%s): %v", role, err)
}
got, _, err := store.GetUserForLogin(ctx, username)
if err != nil {
t.Fatalf("GetUserForLogin after SetRole(%s): %v", role, err)
}
if got.Role != role {
t.Fatalf("role after SetRole(%s) = %q, want %q", role, got.Role, role)
}
}
}
+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;
+6 -221
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,70 +62,10 @@
<section>
<h2>Core</h2>
<p>Single-tenant deployment settings live here. Nothing configurable yet.</p>
</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 localAuthEnabled}
<p class="note">Manage accounts, passwords, and roles from <a href="/users">Users</a>.</p>
{/if}
</section>
{/if}
{#if loading}
<p class="muted">Loading…</p>
@@ -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;