feat: enable enterprise users to specify a custom logo (#5566)

* feat: enable enterprise users to specify a custom logo

This adds a field in deployment settings that allows users to specify
the URL to a custom logo that will display in the dashboard.

This also groups service banner into a new appearance settings page.
It adds a Fieldset component to allow for modular fields moving forward.

* Fix tests
This commit is contained in:
Kyle Carberry
2023-01-04 15:31:45 -06:00
committed by GitHub
parent 175be621cf
commit 0dba2defd1
35 changed files with 824 additions and 597 deletions

View File

@ -0,0 +1,126 @@
package coderd
import (
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/codersdk"
)
func (api *API) appearance(rw http.ResponseWriter, r *http.Request) {
api.entitlementsMu.RLock()
isEntitled := api.entitlements.Features[codersdk.FeatureAppearance].Entitlement == codersdk.EntitlementEntitled
api.entitlementsMu.RUnlock()
ctx := r.Context()
if !isEntitled {
httpapi.Write(ctx, rw, http.StatusOK, codersdk.AppearanceConfig{})
return
}
logoURL, err := api.Database.GetLogoURL(ctx)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to fetch logo URL.",
Detail: err.Error(),
})
return
}
serviceBannerJSON, err := api.Database.GetServiceBanner(r.Context())
if err != nil && !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to fetch service banner.",
Detail: err.Error(),
})
return
}
cfg := codersdk.AppearanceConfig{
LogoURL: logoURL,
}
if serviceBannerJSON != "" {
err = json.Unmarshal([]byte(serviceBannerJSON), &cfg.ServiceBanner)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: fmt.Sprintf(
"unmarshal json: %+v, raw: %s", err, serviceBannerJSON,
),
})
return
}
}
httpapi.Write(r.Context(), rw, http.StatusOK, cfg)
}
func validateHexColor(color string) error {
if len(color) != 7 {
return xerrors.New("expected 7 characters")
}
if color[0] != '#' {
return xerrors.New("no # prefix")
}
_, err := hex.DecodeString(color[1:])
return err
}
func (api *API) putAppearance(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, rbac.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "Insufficient permissions to update appearance",
})
return
}
var appearance codersdk.AppearanceConfig
if !httpapi.Read(ctx, rw, r, &appearance) {
return
}
if appearance.ServiceBanner.Enabled {
if err := validateHexColor(appearance.ServiceBanner.BackgroundColor); err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("parse color: %+v", err),
})
return
}
}
serviceBannerJSON, err := json.Marshal(appearance.ServiceBanner)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("marshal banner: %+v", err),
})
return
}
err = api.Database.InsertOrUpdateServiceBanner(ctx, string(serviceBannerJSON))
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: fmt.Sprintf("database error: %+v", err),
})
return
}
err = api.Database.InsertOrUpdateLogoURL(ctx, appearance.LogoURL)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: fmt.Sprintf("database error: %+v", err),
})
return
}
httpapi.Write(r.Context(), rw, http.StatusOK, appearance)
}

View File

@ -25,24 +25,24 @@ func TestServiceBanners(t *testing.T) {
adminUser := coderdtest.CreateFirstUser(t, adminClient)
// Even without a license, the banner should return as disabled.
sb, err := adminClient.ServiceBanner(ctx)
sb, err := adminClient.Appearance(ctx)
require.NoError(t, err)
require.False(t, sb.Enabled)
require.False(t, sb.ServiceBanner.Enabled)
coderdenttest.AddLicense(t, adminClient, coderdenttest.LicenseOptions{
ServiceBanners: true,
})
// Default state
sb, err = adminClient.ServiceBanner(ctx)
sb, err = adminClient.Appearance(ctx)
require.NoError(t, err)
require.False(t, sb.Enabled)
require.False(t, sb.ServiceBanner.Enabled)
basicUserClient := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID)
// Regular user should be unable to set the banner
sb.Enabled = true
err = basicUserClient.SetServiceBanner(ctx, sb)
sb.ServiceBanner.Enabled = true
err = basicUserClient.UpdateAppearance(ctx, sb)
require.Error(t, err)
var sdkError *codersdk.Error
require.True(t, errors.As(err, &sdkError))
@ -50,17 +50,17 @@ func TestServiceBanners(t *testing.T) {
// But an admin can
wantBanner := sb
wantBanner.Enabled = true
wantBanner.Message = "Hey"
wantBanner.BackgroundColor = "#00FF00"
err = adminClient.SetServiceBanner(ctx, wantBanner)
wantBanner.ServiceBanner.Enabled = true
wantBanner.ServiceBanner.Message = "Hey"
wantBanner.ServiceBanner.BackgroundColor = "#00FF00"
err = adminClient.UpdateAppearance(ctx, wantBanner)
require.NoError(t, err)
gotBanner, err := adminClient.ServiceBanner(ctx)
gotBanner, err := adminClient.Appearance(ctx)
require.NoError(t, err)
require.Equal(t, wantBanner, gotBanner)
// But even an admin can't give a bad color
wantBanner.BackgroundColor = "#bad color"
err = adminClient.SetServiceBanner(ctx, wantBanner)
wantBanner.ServiceBanner.BackgroundColor = "#bad color"
err = adminClient.UpdateAppearance(ctx, wantBanner)
require.Error(t, err)
}

View File

@ -127,12 +127,12 @@ func New(ctx context.Context, options *Options) (*API, error) {
r.Get("/", api.workspaceQuota)
})
})
r.Route("/service-banner", func(r chi.Router) {
r.Route("/appearance", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
)
r.Get("/", api.serviceBanner)
r.Put("/", api.putServiceBanner)
r.Get("/", api.appearance)
r.Put("/", api.putAppearance)
})
})

View File

@ -192,7 +192,7 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
TemplateRBAC: rbacEnabled,
MultipleGitAuth: multipleGitAuth,
ExternalProvisionerDaemons: externalProvisionerDaemons,
ServiceBanners: serviceBanners,
Appearance: serviceBanners,
},
}
tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c)

View File

@ -49,7 +49,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) {
skipRoutes, assertRoute := coderdtest.AGPLRoutes(a)
skipRoutes["GET:/api/v2/organizations/{organization}/provisionerdaemons/serve"] = "This route checks for RBAC dependent on input parameters!"
skipRoutes["GET:/api/v2/service-banner/"] = "This route is available to all users"
skipRoutes["GET:/api/v2/appearance/"] = "This route is available to all users"
assertRoute["GET:/api/v2/entitlements"] = coderdtest.RouteCheck{
NoAuthorize: true,

View File

@ -123,8 +123,8 @@ func Entitlements(
Enabled: true,
}
}
if claims.Features.ServiceBanners > 0 {
entitlements.Features[codersdk.FeatureServiceBanners] = codersdk.Feature{
if claims.Features.Appearance > 0 {
entitlements.Features[codersdk.FeatureAppearance] = codersdk.Feature{
Entitlement: entitlement,
Enabled: true,
}
@ -258,7 +258,7 @@ type Features struct {
HighAvailability int64 `json:"high_availability"`
MultipleGitAuth int64 `json:"multiple_git_auth"`
ExternalProvisionerDaemons int64 `json:"external_provisioner_daemons"`
ServiceBanners int64 `json:"service_banners"`
Appearance int64 `json:"appearance"`
}
type Claims struct {

View File

@ -27,7 +27,7 @@ func TestEntitlements(t *testing.T) {
codersdk.FeatureTemplateRBAC: true,
codersdk.FeatureMultipleGitAuth: true,
codersdk.FeatureExternalProvisionerDaemons: true,
codersdk.FeatureServiceBanners: true,
codersdk.FeatureAppearance: true,
}
t.Run("Defaults", func(t *testing.T) {

View File

@ -109,7 +109,7 @@ func TestGetLicense(t *testing.T) {
codersdk.FeatureTemplateRBAC: json.Number("1"),
codersdk.FeatureMultipleGitAuth: json.Number("0"),
codersdk.FeatureExternalProvisionerDaemons: json.Number("0"),
codersdk.FeatureServiceBanners: json.Number("0"),
codersdk.FeatureAppearance: json.Number("0"),
}, licenses[0].Claims["features"])
assert.Equal(t, int32(2), licenses[1].ID)
assert.Equal(t, "testing2", licenses[1].Claims["account_id"])
@ -123,7 +123,7 @@ func TestGetLicense(t *testing.T) {
codersdk.FeatureTemplateRBAC: json.Number("0"),
codersdk.FeatureMultipleGitAuth: json.Number("0"),
codersdk.FeatureExternalProvisionerDaemons: json.Number("0"),
codersdk.FeatureServiceBanners: json.Number("0"),
codersdk.FeatureAppearance: json.Number("0"),
}, licenses[1].Claims["features"])
})
}

View File

@ -1,109 +0,0 @@
package coderd
import (
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/codersdk"
)
func (api *API) serviceBanner(rw http.ResponseWriter, r *http.Request) {
api.entitlementsMu.RLock()
isEntitled := api.entitlements.Features[codersdk.FeatureServiceBanners].Entitlement == codersdk.EntitlementEntitled
api.entitlementsMu.RUnlock()
ctx := r.Context()
if !isEntitled {
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ServiceBanner{
Enabled: false,
})
return
}
serviceBannerJSON, err := api.Database.GetServiceBanner(r.Context())
if errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ServiceBanner{
Enabled: false,
})
return
} else if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: fmt.Sprintf("database error: %+v", err),
})
return
}
var serviceBanner codersdk.ServiceBanner
err = json.Unmarshal([]byte(serviceBannerJSON), &serviceBanner)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: fmt.Sprintf(
"unmarshal json: %+v, raw: %s", err, serviceBannerJSON,
),
})
return
}
httpapi.Write(r.Context(), rw, http.StatusOK, serviceBanner)
}
func validateHexColor(color string) error {
if len(color) != 7 {
return xerrors.New("expected 7 characters")
}
if color[0] != '#' {
return xerrors.New("no # prefix")
}
_, err := hex.DecodeString(color[1:])
return err
}
func (api *API) putServiceBanner(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, rbac.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "Insufficient permissions to update service banner",
})
return
}
var serviceBanner codersdk.ServiceBanner
if !httpapi.Read(ctx, rw, r, &serviceBanner) {
return
}
if err := validateHexColor(serviceBanner.BackgroundColor); err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("parse color: %+v", err),
})
return
}
serviceBannerJSON, err := json.Marshal(serviceBanner)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("marshal banner: %+v", err),
})
return
}
err = api.Database.InsertOrUpdateServiceBanner(ctx, string(serviceBannerJSON))
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: fmt.Sprintf("database error: %+v", err),
})
return
}
httpapi.Write(r.Context(), rw, http.StatusOK, serviceBanner)
}