Skip to content

Commit 0dba2de

Browse files
authored
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
1 parent 175be62 commit 0dba2de

35 files changed

+824
-597
lines changed

coderd/database/databasefake/databasefake.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ type data struct {
123123
derpMeshKey string
124124
lastUpdateCheck []byte
125125
serviceBanner []byte
126+
logoURL string
126127
lastLicenseID int32
127128
}
128129

@@ -3356,6 +3357,25 @@ func (q *fakeQuerier) GetServiceBanner(_ context.Context) (string, error) {
33563357
return string(q.serviceBanner), nil
33573358
}
33583359

3360+
func (q *fakeQuerier) InsertOrUpdateLogoURL(_ context.Context, data string) error {
3361+
q.mutex.RLock()
3362+
defer q.mutex.RUnlock()
3363+
3364+
q.logoURL = data
3365+
return nil
3366+
}
3367+
3368+
func (q *fakeQuerier) GetLogoURL(_ context.Context) (string, error) {
3369+
q.mutex.RLock()
3370+
defer q.mutex.RUnlock()
3371+
3372+
if q.logoURL == "" {
3373+
return "", sql.ErrNoRows
3374+
}
3375+
3376+
return q.logoURL, nil
3377+
}
3378+
33593379
func (q *fakeQuerier) InsertLicense(
33603380
_ context.Context, arg database.InsertLicenseParams,
33613381
) (database.License, error) {

coderd/database/querier.go

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries.sql.go

Lines changed: 21 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries/siteconfig.sql

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,10 @@ ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'service_ban
2323

2424
-- name: GetServiceBanner :one
2525
SELECT value FROM site_configs WHERE key = 'service_banner';
26+
27+
-- name: InsertOrUpdateLogoURL :exec
28+
INSERT INTO site_configs (key, value) VALUES ('logo_url', $1)
29+
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'logo_url';
30+
31+
-- name: GetLogoURL :one
32+
SELECT value FROM site_configs WHERE key = 'logo_url';

codersdk/appearance.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package codersdk
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
)
8+
9+
type AppearanceConfig struct {
10+
LogoURL string `json:"logo_url"`
11+
ServiceBanner ServiceBannerConfig `json:"service_banner"`
12+
}
13+
14+
type ServiceBannerConfig struct {
15+
Enabled bool `json:"enabled"`
16+
Message string `json:"message,omitempty"`
17+
BackgroundColor string `json:"background_color,omitempty"`
18+
}
19+
20+
func (c *Client) Appearance(ctx context.Context) (AppearanceConfig, error) {
21+
res, err := c.Request(ctx, http.MethodGet, "/api/v2/appearance", nil)
22+
if err != nil {
23+
return AppearanceConfig{}, err
24+
}
25+
defer res.Body.Close()
26+
if res.StatusCode != http.StatusOK {
27+
return AppearanceConfig{}, readBodyAsError(res)
28+
}
29+
var cfg AppearanceConfig
30+
return cfg, json.NewDecoder(res.Body).Decode(&cfg)
31+
}
32+
33+
func (c *Client) UpdateAppearance(ctx context.Context, appearance AppearanceConfig) error {
34+
res, err := c.Request(ctx, http.MethodPut, "/api/v2/appearance", appearance)
35+
if err != nil {
36+
return err
37+
}
38+
defer res.Body.Close()
39+
if res.StatusCode != http.StatusOK {
40+
return readBodyAsError(res)
41+
}
42+
return nil
43+
}

codersdk/branding.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package codersdk
2+
3+
import (
4+
"context"
5+
"net/http"
6+
)
7+
8+
type UpdateBrandingRequest struct {
9+
LogoURL string `json:"logo_url"`
10+
}
11+
12+
// UpdateBranding applies customization settings available to Enterprise customers.
13+
func (c *Client) UpdateBranding(ctx context.Context, req UpdateBrandingRequest) error {
14+
res, err := c.Request(ctx, http.MethodPut, "/api/v2/branding", req)
15+
if err != nil {
16+
return err
17+
}
18+
defer res.Body.Close()
19+
if res.StatusCode != http.StatusOK {
20+
return readBodyAsError(res)
21+
}
22+
return nil
23+
}

codersdk/features.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const (
2323
FeatureHighAvailability = "high_availability"
2424
FeatureMultipleGitAuth = "multiple_git_auth"
2525
FeatureExternalProvisionerDaemons = "external_provisioner_daemons"
26-
FeatureServiceBanners = "service_banners"
26+
FeatureAppearance = "appearance"
2727
)
2828

2929
var FeatureNames = []string{
@@ -35,7 +35,7 @@ var FeatureNames = []string{
3535
FeatureHighAvailability,
3636
FeatureMultipleGitAuth,
3737
FeatureExternalProvisionerDaemons,
38-
FeatureServiceBanners,
38+
FeatureAppearance,
3939
}
4040

4141
type Feature struct {

codersdk/servicebanner.go

Lines changed: 0 additions & 38 deletions
This file was deleted.

enterprise/coderd/appearance.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package coderd
2+
3+
import (
4+
"database/sql"
5+
"encoding/hex"
6+
"encoding/json"
7+
"errors"
8+
"fmt"
9+
"net/http"
10+
11+
"golang.org/x/xerrors"
12+
13+
"github.com/coder/coder/coderd/httpapi"
14+
"github.com/coder/coder/coderd/rbac"
15+
"github.com/coder/coder/codersdk"
16+
)
17+
18+
func (api *API) appearance(rw http.ResponseWriter, r *http.Request) {
19+
api.entitlementsMu.RLock()
20+
isEntitled := api.entitlements.Features[codersdk.FeatureAppearance].Entitlement == codersdk.EntitlementEntitled
21+
api.entitlementsMu.RUnlock()
22+
23+
ctx := r.Context()
24+
25+
if !isEntitled {
26+
httpapi.Write(ctx, rw, http.StatusOK, codersdk.AppearanceConfig{})
27+
return
28+
}
29+
30+
logoURL, err := api.Database.GetLogoURL(ctx)
31+
if err != nil && !errors.Is(err, sql.ErrNoRows) {
32+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
33+
Message: "Failed to fetch logo URL.",
34+
Detail: err.Error(),
35+
})
36+
return
37+
}
38+
39+
serviceBannerJSON, err := api.Database.GetServiceBanner(r.Context())
40+
if err != nil && !errors.Is(err, sql.ErrNoRows) {
41+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
42+
Message: "Failed to fetch service banner.",
43+
Detail: err.Error(),
44+
})
45+
return
46+
}
47+
48+
cfg := codersdk.AppearanceConfig{
49+
LogoURL: logoURL,
50+
}
51+
if serviceBannerJSON != "" {
52+
err = json.Unmarshal([]byte(serviceBannerJSON), &cfg.ServiceBanner)
53+
if err != nil {
54+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
55+
Message: fmt.Sprintf(
56+
"unmarshal json: %+v, raw: %s", err, serviceBannerJSON,
57+
),
58+
})
59+
return
60+
}
61+
}
62+
63+
httpapi.Write(r.Context(), rw, http.StatusOK, cfg)
64+
}
65+
66+
func validateHexColor(color string) error {
67+
if len(color) != 7 {
68+
return xerrors.New("expected 7 characters")
69+
}
70+
if color[0] != '#' {
71+
return xerrors.New("no # prefix")
72+
}
73+
_, err := hex.DecodeString(color[1:])
74+
return err
75+
}
76+
77+
func (api *API) putAppearance(rw http.ResponseWriter, r *http.Request) {
78+
ctx := r.Context()
79+
80+
if !api.Authorize(r, rbac.ActionUpdate, rbac.ResourceDeploymentConfig) {
81+
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
82+
Message: "Insufficient permissions to update appearance",
83+
})
84+
return
85+
}
86+
87+
var appearance codersdk.AppearanceConfig
88+
if !httpapi.Read(ctx, rw, r, &appearance) {
89+
return
90+
}
91+
92+
if appearance.ServiceBanner.Enabled {
93+
if err := validateHexColor(appearance.ServiceBanner.BackgroundColor); err != nil {
94+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
95+
Message: fmt.Sprintf("parse color: %+v", err),
96+
})
97+
return
98+
}
99+
}
100+
101+
serviceBannerJSON, err := json.Marshal(appearance.ServiceBanner)
102+
if err != nil {
103+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
104+
Message: fmt.Sprintf("marshal banner: %+v", err),
105+
})
106+
return
107+
}
108+
109+
err = api.Database.InsertOrUpdateServiceBanner(ctx, string(serviceBannerJSON))
110+
if err != nil {
111+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
112+
Message: fmt.Sprintf("database error: %+v", err),
113+
})
114+
return
115+
}
116+
117+
err = api.Database.InsertOrUpdateLogoURL(ctx, appearance.LogoURL)
118+
if err != nil {
119+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
120+
Message: fmt.Sprintf("database error: %+v", err),
121+
})
122+
return
123+
}
124+
125+
httpapi.Write(r.Context(), rw, http.StatusOK, appearance)
126+
}

0 commit comments

Comments
 (0)