fix installer

This commit is contained in:
Daniel Noulet 2026-10-04 12:24:13 +02:00
parent 318fb51085
commit 6319da650c
31 changed files with 2391 additions and 87 deletions

View file

@ -8,9 +8,35 @@ import (
"time"
"github.com/danyel/go-loose/internal/key"
"github.com/danyel/go-loose/internal/role"
"github.com/danyel/go-loose/internal/store"
)
// clientUserResponse is the identity payload handed to client applications. It
// carries the tenant role and its capabilities so that an application such as
// go-tell can gate features, plus an absolute avatar_url it can render directly.
type clientUserResponse struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
TenantID string `json:"tenant_id"`
TenantSlug string `json:"tenant_slug"`
ApplicationID string `json:"application_id"`
Application string `json:"application"`
Role string `json:"role"`
Permissions []string `json:"permissions"`
}
func (s *Server) clientUser(user store.ClientUser) clientUserResponse {
return clientUserResponse{
ID: user.ID, Email: user.Email, DisplayName: user.DisplayName,
AvatarURL: s.avatarURL(user.AvatarKey), TenantID: user.TenantID,
TenantSlug: user.TenantSlug, ApplicationID: user.ApplicationID, Application: user.Application,
Role: user.Role, Permissions: role.Role(user.Role).Permissions(),
}
}
func (s *Server) clientAuthorize(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
clientID, redirectURI, state := query.Get("client_id"), query.Get("redirect_uri"), query.Get("state")
@ -99,7 +125,7 @@ func (s *Server) clientToken(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]any{
"access_token": token, "token_type": "Bearer",
"expires_in": int(time.Until(expiresAt).Seconds()), "user": user,
"expires_in": int(time.Until(expiresAt).Seconds()), "user": s.clientUser(user),
})
}
@ -118,7 +144,7 @@ func (s *Server) clientUserInfo(w http.ResponseWriter, r *http.Request) {
s.internalError(w, "load client user", err)
return
}
writeJSON(w, http.StatusOK, user)
writeJSON(w, http.StatusOK, s.clientUser(user))
}
func (s *Server) clientLogout(w http.ResponseWriter, r *http.Request) {

143
internal/server/profile.go Normal file
View file

@ -0,0 +1,143 @@
package server
import (
"bytes"
"errors"
"net/http"
"strings"
"time"
"github.com/danyel/go-loose/internal/avatar"
"github.com/danyel/go-loose/internal/store"
)
// maxProfileBody bounds a profile update. It allows an avatar at its 2 MiB
// limit after base64 inflation plus the JSON envelope.
const maxProfileBody = 4 << 20
// maxDisplayName bounds a self-selected display name.
const maxDisplayName = 200
type profileResponse struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
Memberships []store.Membership `json:"memberships"`
IsSystemAdministrator bool `json:"is_system_administrator"`
NameCustomized bool `json:"name_customized"`
CreatedAt time.Time `json:"created_at"`
LastLoginAt time.Time `json:"last_login_at"`
ProfileUpdatedAt *time.Time `json:"profile_updated_at,omitempty"`
AvatarUpdatedAt *time.Time `json:"avatar_updated_at,omitempty"`
}
func (s *Server) profilePage(w http.ResponseWriter, _ *http.Request) {
s.serveEmbedded(w, "profile.html")
}
func (s *Server) profile(w http.ResponseWriter, r *http.Request) {
profile, err := s.store.Profile(r.Context(), claimsFrom(r).UserID)
if err != nil {
s.handleStoreError(w, "load profile", err)
return
}
writeJSON(w, http.StatusOK, s.profileResponse(profile))
}
func (s *Server) updateProfile(w http.ResponseWriter, r *http.Request) {
var request struct {
DisplayName *string `json:"display_name"`
Avatar string `json:"avatar"`
RemoveAvatar bool `json:"remove_avatar"`
}
if !decodeJSONLimit(w, r, maxProfileBody, &request) {
return
}
update := store.ProfileUpdate{}
if request.DisplayName != nil {
name := strings.TrimSpace(*request.DisplayName)
if name == "" || len(name) > maxDisplayName {
writeError(w, http.StatusBadRequest, "display name must be between 1 and 200 characters")
return
}
update.DisplayName = &name
}
switch {
case request.RemoveAvatar && request.Avatar != "":
writeError(w, http.StatusBadRequest, "send either a new picture or remove_avatar, not both")
return
case request.RemoveAvatar:
update.RemoveAvatar = true
case strings.TrimSpace(request.Avatar) != "":
data, contentType, err := avatar.Parse(request.Avatar)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
key, err := avatar.NewKey()
if err != nil {
s.internalError(w, "generate picture key", err)
return
}
update.AvatarKey, update.AvatarData, update.ContentType = key, data, contentType
case update.DisplayName == nil:
writeError(w, http.StatusBadRequest, "a display name or a picture is required")
return
}
if err := s.store.UpdateProfile(r.Context(), claimsFrom(r).UserID, update); err != nil {
s.handleStoreError(w, "update profile", err)
return
}
profile, err := s.store.Profile(r.Context(), claimsFrom(r).UserID)
if err != nil {
s.handleStoreError(w, "reload profile", err)
return
}
writeJSON(w, http.StatusOK, s.profileResponse(profile))
}
// avatar serves a stored profile picture by its unguessable key.
//
// The route is deliberately public. A profile picture is rendered by other
// applications such as go-tell through a plain image tag, which cannot carry
// the authentication cookie, and the key is 256 bits of randomness rather than
// a user identifier. Nothing else about the user is exposed here.
func (s *Server) avatar(w http.ResponseWriter, r *http.Request) {
key := r.PathValue("key")
if !avatar.ValidKey(key) {
writeError(w, http.StatusNotFound, "picture not found")
return
}
picture, err := s.store.Picture(r.Context(), key)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "picture not found")
return
}
s.internalError(w, "load profile picture", err)
return
}
w.Header().Set("Content-Type", picture.ContentType)
w.Header().Set("Cache-Control", "public, max-age=300")
w.Header().Set("ETag", avatar.ETag(picture.Data))
w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox")
http.ServeContent(w, r, "", picture.UpdatedAt, bytes.NewReader(picture.Data))
}
func (s *Server) profileResponse(profile store.Profile) profileResponse {
return profileResponse{
ID: profile.ID, Email: profile.Email, DisplayName: profile.DisplayName,
AvatarURL: s.avatarURL(profile.AvatarKey), Memberships: profile.Memberships,
IsSystemAdministrator: profile.IsSystemAdministrator, NameCustomized: profile.NameCustomized,
CreatedAt: profile.CreatedAt, LastLoginAt: profile.LastLoginAt,
ProfileUpdatedAt: profile.ProfileUpdatedAt, AvatarUpdatedAt: profile.AvatarUpdatedAt,
}
}
func (s *Server) avatarURL(key *string) string {
if key == nil || *key == "" {
return ""
}
return s.cfg.BaseURL + "/api/v1/avatars/" + *key
}

View file

@ -0,0 +1,194 @@
package server
import (
"bytes"
"context"
"encoding/base64"
"image"
"image/color"
"image/png"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strings"
"testing"
"github.com/danyel/go-loose/internal/config"
"github.com/danyel/go-loose/internal/role"
"github.com/danyel/go-loose/internal/session"
"github.com/danyel/go-loose/internal/store"
"github.com/danyel/go-loose/web"
)
func profileTestServer() *Server {
return &Server{cfg: config.Config{BaseURL: "https://auth.example.test"}}
}
func profileRequest(t *testing.T, body string) *http.Request {
t.Helper()
request := httptest.NewRequest(http.MethodPut, "https://auth.example.test/api/v1/profile", strings.NewReader(body))
claims := session.Claims{UserID: "user-1", Management: true}
return request.WithContext(context.WithValue(request.Context(), claimsKey, claims))
}
func TestAvatarURLIsAbsoluteAndOmittedWithoutKey(t *testing.T) {
server := profileTestServer()
if got := server.avatarURL(nil); got != "" {
t.Fatalf("avatarURL(nil) = %q, want empty", got)
}
empty := ""
if got := server.avatarURL(&empty); got != "" {
t.Fatalf("avatarURL(empty) = %q, want empty", got)
}
key := "k"
got := server.avatarURL(&key)
if want := "https://auth.example.test/api/v1/avatars/k"; got != want {
t.Fatalf("avatarURL() = %q, want %q", got, want)
}
parsed, err := url.Parse(got)
if err != nil || parsed.Path != "/api/v1/avatars/k" {
t.Fatalf("avatar url = %q, err = %v", got, err)
}
}
func TestClientUserExposesRoleCapabilitiesAndPicture(t *testing.T) {
server := profileTestServer()
key := "avatar-key"
payload := server.clientUser(store.ClientUser{
ID: "user-1", Email: "operator@example.test", DisplayName: "Operator",
TenantSlug: "nmbs", Application: "guess", Role: string(role.Operator), AvatarKey: &key,
})
if payload.Role != "operator" {
t.Fatalf("role = %q", payload.Role)
}
if !slices.Contains(payload.Permissions, string(role.ManageKeys)) {
t.Fatalf("permissions = %v, want keys.manage", payload.Permissions)
}
if slices.Contains(payload.Permissions, string(role.ManageUsers)) {
t.Fatalf("permissions = %v, must not grant users.manage to an operator", payload.Permissions)
}
if !strings.HasSuffix(payload.AvatarURL, "/api/v1/avatars/avatar-key") {
t.Fatalf("avatar url = %q", payload.AvatarURL)
}
}
func TestClientUserWithoutMembershipIsLeastPrivileged(t *testing.T) {
server := profileTestServer()
payload := server.clientUser(store.ClientUser{Role: string(role.User)})
if payload.AvatarURL != "" {
t.Fatalf("avatar url = %q, want empty", payload.AvatarURL)
}
if !slices.Equal(payload.Permissions, []string{string(role.EditProfile)}) {
t.Fatalf("permissions = %v, want only profile.edit", payload.Permissions)
}
}
func TestAvatarRejectsMalformedKeyWithoutQueryingTheDatabase(t *testing.T) {
server := profileTestServer()
for _, key := range []string{"", "short", "../../etc/passwd", strings.Repeat("a", 44)} {
request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/api/v1/avatars/"+key, nil)
request.SetPathValue("key", key)
response := httptest.NewRecorder()
server.avatar(response, request)
if response.Code != http.StatusNotFound {
t.Fatalf("avatar(%q) status = %d, want %d", key, response.Code, http.StatusNotFound)
}
if body := response.Body.String(); strings.Contains(body, "avatar") {
t.Fatalf("avatar(%q) leaked internals: %s", key, body)
}
}
}
func TestUpdateProfileRejectsInvalidRequests(t *testing.T) {
server := profileTestServer()
picture := base64.StdEncoding.EncodeToString(tinyPNG(t))
tests := []struct {
name string
body string
}{
{name: "unknown field", body: `{"display_name":"Ana","admin":true}`},
{name: "two JSON objects", body: `{"display_name":"Ana"}{"display_name":"Bo"}`},
{name: "blank display name", body: `{"display_name":" "}`},
{name: "oversized display name", body: `{"display_name":"` + strings.Repeat("a", maxDisplayName+1) + `"}`},
{name: "picture and removal", body: `{"avatar":"` + picture + `","remove_avatar":true}`},
{name: "picture is not base64", body: `{"avatar":"!!!not base64!!!"}`},
{name: "picture is not an image", body: `{"avatar":"` + base64.StdEncoding.EncodeToString([]byte("<svg/>")) + `"}`},
{name: "picture media type mismatch", body: `{"avatar":"data:image/gif;base64,` + picture + `"}`},
{name: "nothing to update", body: `{}`},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
response := httptest.NewRecorder()
server.updateProfile(response, profileRequest(t, test.body))
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d (body %s)", response.Code, http.StatusBadRequest, response.Body)
}
})
}
}
func TestUpdateProfileRejectsOversizedBody(t *testing.T) {
server := profileTestServer()
body := `{"avatar":"` + strings.Repeat("A", maxProfileBody) + `"}`
response := httptest.NewRecorder()
server.updateProfile(response, profileRequest(t, body))
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", response.Code, http.StatusBadRequest)
}
}
func TestInvitableRoleExcludesOwner(t *testing.T) {
if _, ok := invitableRole(string(role.Owner)); ok {
t.Fatal("an invitation must not be able to grant ownership")
}
for _, item := range role.Grantable() {
if _, ok := invitableRole(string(item)); !ok {
t.Fatalf("invitableRole(%q) = false", item)
}
}
for _, item := range []string{"", "superuser", "Owner", "user'", "owner"} {
if item == string(role.Owner) {
continue
}
if _, ok := invitableRole(item); ok {
t.Fatalf("invitableRole(%q) = true", item)
}
}
}
func TestAssignableRoleAcceptsEveryKnownRole(t *testing.T) {
for _, item := range role.All() {
if got, ok := assignableRole(string(item)); !ok || got != string(item) {
t.Fatalf("assignableRole(%q) = %q, %v", item, got, ok)
}
}
for _, item := range []string{"", "superuser", "Owner", "admin,owner"} {
if _, ok := assignableRole(item); ok {
t.Fatalf("assignableRole(%q) = true", item)
}
}
}
func TestProfilePageIsEmbedded(t *testing.T) {
content, err := web.Files.ReadFile("profile.html")
if err != nil {
t.Fatalf("profile.html must be embedded: %v", err)
}
for _, marker := range []string{"avatar-file", "profile-form", "/assets/profile.js"} {
if !bytes.Contains(content, []byte(marker)) {
t.Fatalf("profile.html is missing %q", marker)
}
}
}
func tinyPNG(t *testing.T) []byte {
t.Helper()
canvas := image.NewRGBA(image.Rect(0, 0, 2, 2))
canvas.Set(0, 0, color.RGBA{R: 255, A: 255})
buffer := &bytes.Buffer{}
if err := png.Encode(buffer, canvas); err != nil {
t.Fatalf("encode png: %v", err)
}
return buffer.Bytes()
}

View file

@ -22,6 +22,7 @@ import (
"github.com/danyel/go-loose/internal/config"
"github.com/danyel/go-loose/internal/contract"
"github.com/danyel/go-loose/internal/key"
"github.com/danyel/go-loose/internal/role"
"github.com/danyel/go-loose/internal/secretbox"
"github.com/danyel/go-loose/internal/session"
"github.com/danyel/go-loose/internal/store"
@ -107,6 +108,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /auth/logout", s.authLogout)
mux.HandleFunc("POST /auth/password", s.passwordLogin)
mux.HandleFunc("GET /assets/{name}", s.asset)
mux.HandleFunc("GET /api/v1/avatars/{key}", s.avatar)
mux.HandleFunc("POST /api/v1/authorize", s.authorize)
mux.HandleFunc("GET /connect/authorize", s.clientAuthorize)
mux.HandleFunc("POST /connect/token", s.clientToken)
@ -117,6 +119,9 @@ func (s *Server) Handler() http.Handler {
// {$} matches only "/". The unanchored pattern "GET /" is a catch-all, so a
// browser favicon request would redirect to /install and rotate its CSRF cookie.
mux.Handle("GET /{$}", s.requireSession(http.HandlerFunc(s.index)))
mux.Handle("GET /profile", s.requireSession(http.HandlerFunc(s.profilePage)))
mux.Handle("GET /api/v1/profile", s.requireSession(http.HandlerFunc(s.profile)))
mux.Handle("PUT /api/v1/profile", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.updateProfile))))
mux.Handle("GET /docs", s.requireSession(http.HandlerFunc(s.docs)))
mux.Handle("GET /openapi.json", s.requireSession(http.HandlerFunc(s.openapi)))
mux.Handle("GET /api/v1/dashboard", s.requireSession(http.HandlerFunc(s.dashboard)))
@ -155,16 +160,26 @@ func (s *Server) clientLoginPage(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
allowed, err := s.store.HasManagementAccess(r.Context(), claimsFrom(r).UserID)
claims := claimsFrom(r)
console, err := s.store.HasConsoleAccess(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "check management access", err)
s.internalError(w, "check console access", err)
return
}
if !allowed {
s.serveEmbedded(w, "waiting-room.html")
if console {
s.serveEmbedded(w, "index.html")
return
}
s.serveEmbedded(w, "index.html")
member, err := s.store.HasMembership(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "check membership", err)
return
}
if member {
http.Redirect(w, r, "/profile", http.StatusSeeOther)
return
}
s.serveEmbedded(w, "waiting-room.html")
}
func (s *Server) docs(w http.ResponseWriter, _ *http.Request) {
@ -177,7 +192,7 @@ func (s *Server) openapi(w http.ResponseWriter, _ *http.Request) {
func (s *Server) asset(w http.ResponseWriter, r *http.Request) {
name := path.Base(r.PathValue("name"))
if name != "style.css" && name != "app.js" {
if name != "style.css" && name != "app.js" && name != "profile.js" {
http.NotFound(w, r)
return
}
@ -330,13 +345,13 @@ func (s *Server) authLogout(w http.ResponseWriter, r *http.Request) {
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
claims := claimsFrom(r)
allowed, err := s.store.HasManagementAccess(r.Context(), claims.UserID)
allowed, err := s.store.HasConsoleAccess(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "check management access", err)
s.internalError(w, "check console access", err)
return
}
if !allowed {
writeError(w, http.StatusForbidden, "waiting for administrator approval")
writeError(w, http.StatusForbidden, "this account has no console access")
return
}
tenants, err := s.store.ListTenants(r.Context(), claims.UserID)
@ -374,9 +389,19 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
s.internalError(w, "check system administrator", err)
return
}
profile, err := s.store.Profile(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "load profile", err)
return
}
for index := range users {
users[index].AvatarURL = s.avatarURL(users[index].AvatarKey)
}
writeJSON(w, http.StatusOK, map[string]any{
"user": claims, "tenants": tenants, "applications": applications, "keys": keys, "contracts": contracts,
"users": users, "pending_users": pendingUsers, "is_system_administrator": isSystemAdministrator,
"user": claims, "profile": s.profileResponse(profile),
"tenants": tenants, "applications": applications, "keys": keys, "contracts": contracts,
"users": users, "pending_users": pendingUsers, "roles": role.Catalog(),
"is_system_administrator": isSystemAdministrator,
})
}
@ -703,7 +728,11 @@ func validSlug(value string) bool {
}
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
r.Body = http.MaxBytesReader(w, r.Body, 6<<20)
return decodeJSONLimit(w, r, 6<<20, target)
}
func decodeJSONLimit(w http.ResponseWriter, r *http.Request, limit int64, target any) bool {
r.Body = http.MaxBytesReader(w, r.Body, limit)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {

View file

@ -138,12 +138,14 @@ func (s *Server) renderManagementLogin(w http.ResponseWriter, r *http.Request) {
}
tenantSlug := s.requestTenantSlug(r.Host)
tenantLogin := tenantSlug != ""
loginPrompt := "admin@goloose:~"
loginContext := "SYSTEM ADMINISTRATION"
loginMessage := "Sign in through the configured identity provider. System administration is not tied to a tenant."
passwordButton := "LOGIN"
if tenantLogin {
loginContext = strings.ToUpper(tenantSlug) + " · TENANT ADMINISTRATION"
loginMessage = "Sign in with your tenant database account."
loginPrompt = tenantSlug + "@goloose:~"
passwordButton = "LOGIN TO " + strings.ToUpper(tenantSlug)
}
page := strings.NewReplacer(
@ -153,6 +155,7 @@ func (s *Server) renderManagementLogin(w http.ResponseWriter, r *http.Request) {
"{{PASSWORD_BUTTON}}", html.EscapeString(passwordButton),
"{{LOCAL_STYLE}}", hiddenStyle(!tenantLogin || !s.cfg.LocalLogin),
"{{SSO_STYLE}}", hiddenStyle(tenantLogin || !s.hasOIDC()),
"{{LOGIN_PROMPT}}", loginPrompt,
).Replace(string(content))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(page))

View file

@ -8,8 +8,28 @@ import (
"github.com/danyel/go-loose/internal/key"
"github.com/danyel/go-loose/internal/password"
"github.com/danyel/go-loose/internal/role"
)
// assignableRole validates a role that an administrator may hand to an existing
// member. Any known role qualifies, including owner, so that ownership can move
// between accounts deliberately.
func assignableRole(value string) (string, bool) {
parsed, ok := role.Parse(value)
return string(parsed), ok
}
// invitableRole validates a role for a new invitation. Ownership is established
// by creating or claiming a tenant, so it is never granted here.
func invitableRole(value string) (string, bool) {
for _, item := range role.Grantable() {
if string(item) == value {
return value, true
}
}
return "", false
}
func (s *Server) configureClient(w http.ResponseWriter, r *http.Request) {
var request struct {
RedirectURIs []string `json:"redirect_uris"`
@ -75,8 +95,9 @@ func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) {
if request.DisplayName == "" {
request.DisplayName = request.Email
}
if request.Role != "admin" && request.Role != "viewer" {
writeError(w, http.StatusBadRequest, "role must be admin or viewer")
invitedRole, ok := invitableRole(request.Role)
if !ok {
writeError(w, http.StatusBadRequest, "role must be one of "+strings.Join(role.GrantableValues(), ", "))
return
}
passwordHash, err := password.Hash(request.Password)
@ -84,11 +105,12 @@ func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
user, err := s.store.InviteUser(r.Context(), claimsFrom(r).UserID, request.TenantID, request.Email, request.DisplayName, request.Role, passwordHash)
user, err := s.store.InviteUser(r.Context(), claimsFrom(r).UserID, request.TenantID, request.Email, request.DisplayName, invitedRole, passwordHash)
if err != nil {
s.handleStoreError(w, "invite user", err)
return
}
user.AvatarURL = s.avatarURL(user.AvatarKey)
writeJSON(w, http.StatusCreated, user)
}
@ -101,11 +123,12 @@ func (s *Server) setUserAccess(w http.ResponseWriter, r *http.Request) {
if !decodeJSON(w, r, &request) {
return
}
if request.Role != "owner" && request.Role != "admin" && request.Role != "viewer" {
membershipRole, ok := assignableRole(request.Role)
if !ok {
writeError(w, http.StatusBadRequest, "invalid role")
return
}
if err := s.store.SetUserAccess(r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"), request.Role, request.ApplicationIDs); err != nil {
if err := s.store.SetUserAccess(r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"), membershipRole, request.ApplicationIDs); err != nil {
s.handleStoreError(w, "update user access", err)
return
}
@ -121,13 +144,14 @@ func (s *Server) approveUser(w http.ResponseWriter, r *http.Request) {
if !decodeJSON(w, r, &request) {
return
}
if request.Role != "owner" && request.Role != "admin" && request.Role != "viewer" {
membershipRole, ok := assignableRole(request.Role)
if !ok {
writeError(w, http.StatusBadRequest, "invalid role")
return
}
if err := s.store.ApprovePendingUser(
r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"),
request.Role, request.ApplicationIDs,
membershipRole, request.ApplicationIDs,
); err != nil {
s.handleStoreError(w, "approve pending user", err)
return