package adminapi import ( "errors" "net/http" "strconv" "github.com/dariuszjaniak/warp/server/internal/httputil" "github.com/dariuszjaniak/warp/server/internal/storage" "github.com/dariuszjaniak/warp/server/internal/userapi" ) // adminUser is one row of the user list. It is a flatter shape than the // Client-Server API's profile object because the panel shows it as a table. type adminUser struct { UserID string `json:"user_id"` DisplayName string `json:"display_name,omitempty"` AvatarURL string `json:"avatar_url,omitempty"` Admin bool `json:"admin"` Deactivated bool `json:"deactivated"` CreatedAtMS int64 `json:"created_at_ms"` } type usersResponse struct { Users []adminUser `json:"users"` // Total is the number of users matching the query, not the number // returned, so the panel can page. Total int `json:"total"` Limit int `json:"limit"` Offset int `json:"offset"` } // defaultUserPageLimit is what a request without ?limit= gets. const defaultUserPageLimit = 50 // handleGetUsers implements GET /_warp/admin/v1/users. func (a *AdminAPI) handleGetUsers(w http.ResponseWriter, r *http.Request, _ userapi.Authenticated) error { query := r.URL.Query() limit, err := intParam(query.Get("limit"), defaultUserPageLimit) if err != nil { return httputil.InvalidParam("limit must be a number") } offset, err := intParam(query.Get("offset"), 0) if err != nil { return httputil.InvalidParam("offset must be a number") } if limit < 0 || offset < 0 { return httputil.InvalidParam("limit and offset must not be negative") } accounts, total, err := a.users.ListAccounts(r.Context(), storage.AccountFilter{ Search: query.Get("search"), Limit: limit, Offset: offset, }) if err != nil { return err } users := make([]adminUser, 0, len(accounts)) for _, acc := range accounts { users = append(users, adminUser{ UserID: a.users.UserID(acc.Localpart), DisplayName: acc.DisplayName, AvatarURL: acc.AvatarURL, Admin: acc.Admin, Deactivated: acc.Deactivated, CreatedAtMS: acc.CreatedAtMS, }) } httputil.WriteJSON(w, http.StatusOK, usersResponse{ Users: users, Total: total, Limit: limit, Offset: offset, }) return nil } // createUserRequest is the body of POST /_warp/admin/v1/users. type createUserRequest struct { // Username is the localpart; it is normalised like any other. Username string `json:"username"` Password string `json:"password"` DisplayName string `json:"display_name"` Admin bool `json:"admin"` } // handlePostUsers implements POST /_warp/admin/v1/users. // // This is how an operator adds an account on a server with registration // turned off, so it deliberately ignores the registration switch: it is the // admin's own action, not a public sign-up. It does require a password — // unlike `warp create-account`, which may leave one unset — because an // account created from the panel is meant to be handed to somebody. func (a *AdminAPI) handlePostUsers(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error { var req createUserRequest if err := httputil.DecodeJSON(w, r, &req); err != nil { return err } if req.Username == "" { return httputil.MissingParam("username is required") } if req.Password == "" { return httputil.MissingParam("password is required") } if len(req.Password) > userapi.MaxPasswordLength { return httputil.NewError(http.StatusBadRequest, httputil.ErrCodeWeakPassword, "Password must be at most %d bytes", userapi.MaxPasswordLength) } account, err := a.users.CreateAccount(r.Context(), userapi.NewAccountRequest{ Localpart: req.Username, Password: req.Password, DisplayName: req.DisplayName, Admin: req.Admin, }) switch { case errors.Is(err, userapi.ErrUserExists): return httputil.UserInUse() case errors.Is(err, userapi.ErrInvalidLocalpart): return httputil.InvalidUsername("%s", err.Error()) case err != nil: return err } a.audit(r.Context(), auth, auditUserCreate, a.users.UserID(account.Localpart), map[string]any{"admin": account.Admin}) httputil.WriteJSON(w, http.StatusCreated, adminUser{ UserID: a.users.UserID(account.Localpart), DisplayName: account.DisplayName, Admin: account.Admin, CreatedAtMS: account.CreatedAtMS, }) return nil } // intParam parses an optional integer query parameter. func intParam(raw string, fallback int) (int, error) { if raw == "" { return fallback, nil } return strconv.Atoi(raw) }