Settings: fold Users and Organization into the Settings tabs
The left rail is back to the three places you actually go — Garage, Charging, Settings — and user management moves inside Settings as an admin-only tab, next to a new Organization tab that used to be a card buried in the personal settings. Tab order is Personal settings, Users, Organization, Integrations. /admin redirects to /settings?tab=users so old links keep working, and ?tab= picks the starting tab in general. AdminUsers moves from views/ to components/ since it is a panel now, not a route, and its page header becomes a section header like its neighbours. The personal panel was split in two around the integrations markup, which left no gap between the Profile and Privacy cards; it is one block again. Creating a user gets an organization picker for superadmins, defaulting to "no organization" so an org-less account stays a deliberate choice. Admins see no picker: the server pins their members to their own org regardless, which users_test.go now covers along with both superadmin paths. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
cd16d4383f
commit
e373497958
@@ -0,0 +1,194 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"drivervault/apiserver/internal/config"
|
||||
"drivervault/apiserver/internal/pb"
|
||||
)
|
||||
|
||||
// These tests cover who a newly created account belongs to, through the real
|
||||
// Handler + middleware chain: an admin's members are pinned to the admin's own
|
||||
// organization no matter what the client sends, while a superadmin picks the
|
||||
// organization freely — including omitting it to create an org-less account.
|
||||
// A stand-in PocketBase (userFakePB) serves the calls those paths hit.
|
||||
|
||||
const (
|
||||
userTestCallerID = "u1"
|
||||
userTestBearer = "user-bearer-token"
|
||||
userTestNewID = "newuser1"
|
||||
userTestOrgA = "orgA" // the caller's own organization
|
||||
userTestOrgB = "orgB" // some other organization
|
||||
)
|
||||
|
||||
// userFakePB answers the identity and user-create calls, recording the create
|
||||
// payload so tests can assert on the organization that was actually written.
|
||||
type userFakePB struct {
|
||||
mu sync.Mutex
|
||||
|
||||
// Identity returned by auth-refresh.
|
||||
callerRole string
|
||||
callerOrg string
|
||||
|
||||
// Recorded effects.
|
||||
createdUsers []map[string]any
|
||||
}
|
||||
|
||||
func (f *userFakePB) handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Service-account auth (pb.Client.Authenticate).
|
||||
mux.HandleFunc("POST /api/collections/_superusers/auth-with-password", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"token": "svc-token"})
|
||||
})
|
||||
|
||||
// Identify the bearer (withAuth → identify → AuthRefresh).
|
||||
mux.HandleFunc("POST /api/collections/users/auth-refresh", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
writeJSON(w, 401, map[string]any{})
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
writeJSON(w, 200, map[string]any{"record": map[string]any{
|
||||
"id": userTestCallerID, "email": "boss@test.local", "name": "Boss",
|
||||
"role": f.callerRole, "organization": f.callerOrg,
|
||||
}})
|
||||
})
|
||||
|
||||
// User create.
|
||||
mux.HandleFunc("POST /api/collections/users/records", func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
f.mu.Lock()
|
||||
f.createdUsers = append(f.createdUsers, body)
|
||||
f.mu.Unlock()
|
||||
org, _ := body["organization"].(string)
|
||||
writeJSON(w, 200, map[string]any{
|
||||
"id": userTestNewID, "email": body["email"], "name": body["name"],
|
||||
"role": body["role"], "verified": true, "organization": org,
|
||||
"created": "2026-08-17 10:00:00Z",
|
||||
})
|
||||
})
|
||||
|
||||
// Organization name lookup for the created record.
|
||||
mux.HandleFunc("GET /api/collections/organizations/records/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"id": r.PathValue("id"), "name": "Org " + r.PathValue("id")})
|
||||
})
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
// newUserTestServer wires the fake PocketBase to a real Handler and returns the
|
||||
// app's base URL.
|
||||
func newUserTestServer(t *testing.T, f *userFakePB) string {
|
||||
t.Helper()
|
||||
pbSrv := httptest.NewServer(f.handler())
|
||||
t.Cleanup(pbSrv.Close)
|
||||
|
||||
s := New(config.Config{UsersCollection: "users"}, pb.New(pbSrv.URL, "admin@test.local", "pw"))
|
||||
appSrv := httptest.NewServer(s.Handler())
|
||||
t.Cleanup(appSrv.Close)
|
||||
return appSrv.URL
|
||||
}
|
||||
|
||||
// An admin's new members join the admin's own organization, even when the
|
||||
// request asks for a different one.
|
||||
func TestCreateUserAdminForcesOwnOrganization(t *testing.T) {
|
||||
f := &userFakePB{callerRole: roleAdmin, callerOrg: userTestOrgA}
|
||||
base := newUserTestServer(t, f)
|
||||
|
||||
status, body := orgReq(t, base, http.MethodPost, "/api/users", map[string]any{
|
||||
"email": "member@test.local", "password": "hunter2hunter2",
|
||||
"name": "Member", "role": "user", "organization": userTestOrgB,
|
||||
})
|
||||
if status != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201 (body %v)", status, body)
|
||||
}
|
||||
if len(f.createdUsers) != 1 {
|
||||
t.Fatalf("created users = %d, want 1 (%v)", len(f.createdUsers), f.createdUsers)
|
||||
}
|
||||
if got := f.createdUsers[0]["organization"]; got != userTestOrgA {
|
||||
t.Errorf("created organization = %v, want the admin's own org %q", got, userTestOrgA)
|
||||
}
|
||||
}
|
||||
|
||||
// An admin with no organization has nowhere to put a member, so the create is
|
||||
// refused rather than producing a stray org-less account.
|
||||
func TestCreateUserAdminWithoutOrganizationRejected(t *testing.T) {
|
||||
f := &userFakePB{callerRole: roleAdmin, callerOrg: ""}
|
||||
base := newUserTestServer(t, f)
|
||||
|
||||
status, body := orgReq(t, base, http.MethodPost, "/api/users", map[string]any{
|
||||
"email": "member@test.local", "password": "hunter2hunter2",
|
||||
})
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403 (body %v)", status, body)
|
||||
}
|
||||
if len(f.createdUsers) != 0 {
|
||||
t.Errorf("created users = %v, want none", f.createdUsers)
|
||||
}
|
||||
}
|
||||
|
||||
// A superadmin picks the organization, and it is passed through untouched.
|
||||
func TestCreateUserSuperadminHonoursChosenOrganization(t *testing.T) {
|
||||
f := &userFakePB{callerRole: roleSuperadmin, callerOrg: ""}
|
||||
base := newUserTestServer(t, f)
|
||||
|
||||
status, body := orgReq(t, base, http.MethodPost, "/api/users", map[string]any{
|
||||
"email": "member@test.local", "password": "hunter2hunter2",
|
||||
"name": "Member", "role": "admin", "organization": userTestOrgB,
|
||||
})
|
||||
if status != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201 (body %v)", status, body)
|
||||
}
|
||||
if len(f.createdUsers) != 1 {
|
||||
t.Fatalf("created users = %d, want 1 (%v)", len(f.createdUsers), f.createdUsers)
|
||||
}
|
||||
if got := f.createdUsers[0]["organization"]; got != userTestOrgB {
|
||||
t.Errorf("created organization = %v, want %q", got, userTestOrgB)
|
||||
}
|
||||
user, _ := body["user"].(map[string]any)
|
||||
if user["organization"] != userTestOrgB {
|
||||
t.Errorf("returned organization = %v, want %q", user["organization"], userTestOrgB)
|
||||
}
|
||||
if user["organizationName"] != "Org "+userTestOrgB {
|
||||
t.Errorf("returned organizationName = %v, want the resolved name", user["organizationName"])
|
||||
}
|
||||
}
|
||||
|
||||
// Leaving the picker empty is a deliberate choice: the account is created with
|
||||
// no organization at all, rather than the field being rejected or defaulted.
|
||||
func TestCreateUserSuperadminCanOmitOrganization(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
}{
|
||||
{"field absent", map[string]any{"email": "solo@test.local", "password": "hunter2hunter2"}},
|
||||
{"field empty", map[string]any{"email": "solo@test.local", "password": "hunter2hunter2", "organization": ""}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := &userFakePB{callerRole: roleSuperadmin, callerOrg: ""}
|
||||
base := newUserTestServer(t, f)
|
||||
|
||||
status, body := orgReq(t, base, http.MethodPost, "/api/users", tc.body)
|
||||
if status != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201 (body %v)", status, body)
|
||||
}
|
||||
if len(f.createdUsers) != 1 {
|
||||
t.Fatalf("created users = %d, want 1 (%v)", len(f.createdUsers), f.createdUsers)
|
||||
}
|
||||
if got, ok := f.createdUsers[0]["organization"]; ok {
|
||||
t.Errorf("created organization = %v, want the field to be omitted entirely", got)
|
||||
}
|
||||
user, _ := body["user"].(map[string]any)
|
||||
if user["organization"] != "" {
|
||||
t.Errorf("returned organization = %v, want empty", user["organization"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user