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:
@@ -100,6 +100,20 @@ func (f *fakeStore) SetPasswordHash(_ context.Context, userID, hash string) erro
|
|||||||
return nil
|
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) {
|
func (f *fakeStore) CountLocalUsers(_ context.Context) (int, error) {
|
||||||
return len(f.users), nil
|
return len(f.users), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type store interface {
|
|||||||
GetUserByID(ctx context.Context, id string) (*User, error)
|
GetUserByID(ctx context.Context, id string) (*User, error)
|
||||||
DeleteUser(ctx context.Context, id string) error
|
DeleteUser(ctx context.Context, id string) error
|
||||||
SetPasswordHash(ctx context.Context, userID, hash 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)
|
CreateSession(ctx context.Context, userID, tenantID string, role authz.Role, ttl time.Duration) (string, error)
|
||||||
DeleteSessionByHash(ctx context.Context, tokenHash 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("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("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("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 {
|
type loginRequest struct {
|
||||||
@@ -284,6 +286,38 @@ func (h *Handler) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
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 {
|
type resetPasswordRequest struct {
|
||||||
// Password is optional -- omitted, a random one is generated and
|
// Password is optional -- omitted, a random one is generated and
|
||||||
// returned in the response body exactly once, same "shown once,
|
// returned in the response body exactly once, same "shown once,
|
||||||
|
|||||||
@@ -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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -229,6 +229,35 @@ func (s *Store) SetPasswordHash(ctx context.Context, userID, hash string) error
|
|||||||
return tx.Commit(ctx)
|
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
|
// CountLocalUsers backs -seed-admin's idempotency check (see
|
||||||
// cmd/api/main.go's runSeedAdmin): a deployment that already has at
|
// cmd/api/main.go's runSeedAdmin): a deployment that already has at
|
||||||
// least one local user never gets a second auto-created admin account.
|
// least one local user never gets a second auto-created admin account.
|
||||||
|
|||||||
@@ -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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 ---------------------------------------------------------
|
// --- alerting ---------------------------------------------------------
|
||||||
|
|
||||||
export type ConditionType = 'threshold' | 'absence';
|
export type ConditionType = 'threshold' | 'absence';
|
||||||
|
|||||||
@@ -18,15 +18,16 @@
|
|||||||
onCloseMobile
|
onCloseMobile
|
||||||
}: { onOpenPalette: () => void; mobileOpen?: boolean; onCloseMobile?: () => void } = $props();
|
}: { onOpenPalette: () => void; mobileOpen?: boolean; onCloseMobile?: () => void } = $props();
|
||||||
|
|
||||||
const navItems = [
|
const baseNavItems = [
|
||||||
{ href: '/', label: 'Search', icon: '◇' },
|
{ href: '/', label: 'Search', icon: '◇' },
|
||||||
{ href: '/dashboards', label: 'Dashboards', icon: '▤' },
|
{ href: '/dashboards', label: 'Dashboards', icon: '▤' },
|
||||||
{ href: '/alerts', label: 'Alerts', icon: '▲' },
|
{ href: '/alerts', label: 'Alerts', icon: '▲' },
|
||||||
{ href: '/data-sources', label: 'Data Sources', icon: '◈' },
|
{ href: '/data-sources', label: 'Data Sources', icon: '◈' },
|
||||||
{ href: '/agents', label: 'Agents', icon: '●' },
|
{ href: '/agents', label: 'Agents', icon: '●' },
|
||||||
{ href: '/hosts', label: 'Hosts', icon: '▣' },
|
{ href: '/hosts', label: 'Hosts', icon: '▣' }
|
||||||
{ href: '/settings', label: 'Settings', icon: '⚙' }
|
|
||||||
];
|
];
|
||||||
|
const usersNavItem = { href: '/users', label: 'Users', icon: '◐' };
|
||||||
|
const settingsNavItem = { href: '/settings', label: 'Settings', icon: '⚙' };
|
||||||
|
|
||||||
function isActive(href: string): boolean {
|
function isActive(href: string): boolean {
|
||||||
if (href === '/') return page.url.pathname === '/';
|
if (href === '/') return page.url.pathname === '/';
|
||||||
@@ -44,6 +45,18 @@
|
|||||||
getLocalSession().then((s) => (localSession = s === 'disabled' ? null : s));
|
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);
|
let loggingOut = $state(false);
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
loggingOut = true;
|
loggingOut = true;
|
||||||
|
|||||||
@@ -1,17 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import { getAuthFeatures, enterpriseAuthBase, localAuthEnabled, type AuthFeatures } from '$lib/api';
|
||||||
getAuthFeatures,
|
|
||||||
enterpriseAuthBase,
|
|
||||||
localAuthEnabled,
|
|
||||||
getLocalSession,
|
|
||||||
listUsers,
|
|
||||||
createUser,
|
|
||||||
deleteUser,
|
|
||||||
resetPassword,
|
|
||||||
type AuthFeatures,
|
|
||||||
type LocalSession,
|
|
||||||
type LocalUser
|
|
||||||
} from '$lib/api';
|
|
||||||
import { getTheme, setTheme, type Theme } from '$lib/theme.svelte';
|
import { getTheme, setTheme, type Theme } from '$lib/theme.svelte';
|
||||||
import { getDensity, setDensity, type Density } from '$lib/density.svelte';
|
import { getDensity, setDensity, type Density } from '$lib/density.svelte';
|
||||||
|
|
||||||
@@ -25,75 +13,6 @@
|
|||||||
}
|
}
|
||||||
load();
|
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 }[] = [
|
const themeOptions: { value: Theme; label: string; hint: string }[] = [
|
||||||
{ value: 'dark', label: 'Dark', hint: 'Default' },
|
{ value: 'dark', label: 'Dark', hint: 'Default' },
|
||||||
{ value: 'light', label: 'Light', hint: '' },
|
{ value: 'light', label: 'Light', hint: '' },
|
||||||
@@ -143,70 +62,10 @@
|
|||||||
<section>
|
<section>
|
||||||
<h2>Core</h2>
|
<h2>Core</h2>
|
||||||
<p>Single-tenant deployment settings live here. Nothing configurable yet.</p>
|
<p>Single-tenant deployment settings live here. Nothing configurable yet.</p>
|
||||||
</section>
|
{#if localAuthEnabled}
|
||||||
|
<p class="note">Manage accounts, passwords, and roles from <a href="/users">Users</a>.</p>
|
||||||
{#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}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p class="muted">Loading…</p>
|
<p class="muted">Loading…</p>
|
||||||
@@ -256,6 +115,9 @@
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
|
.note a {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
.option-group {
|
.option-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
@@ -298,81 +160,4 @@
|
|||||||
.option.selected .option-hint {
|
.option.selected .option-hint {
|
||||||
color: var(--color-text);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Same shape as settings/+page.ts: no route params, data comes from a
|
||||||
|
// client-side fetch.
|
||||||
|
export const prerender = true;
|
||||||
Reference in New Issue
Block a user