mirror of https://github.com/coder/coder.git
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:
parent
175be621cf
commit
0dba2defd1
|
@ -123,6 +123,7 @@ type data struct {
|
|||
derpMeshKey string
|
||||
lastUpdateCheck []byte
|
||||
serviceBanner []byte
|
||||
logoURL string
|
||||
lastLicenseID int32
|
||||
}
|
||||
|
||||
|
@ -3356,6 +3357,25 @@ func (q *fakeQuerier) GetServiceBanner(_ context.Context) (string, error) {
|
|||
return string(q.serviceBanner), nil
|
||||
}
|
||||
|
||||
func (q *fakeQuerier) InsertOrUpdateLogoURL(_ context.Context, data string) error {
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
|
||||
q.logoURL = data
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *fakeQuerier) GetLogoURL(_ context.Context) (string, error) {
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
|
||||
if q.logoURL == "" {
|
||||
return "", sql.ErrNoRows
|
||||
}
|
||||
|
||||
return q.logoURL, nil
|
||||
}
|
||||
|
||||
func (q *fakeQuerier) InsertLicense(
|
||||
_ context.Context, arg database.InsertLicenseParams,
|
||||
) (database.License, error) {
|
||||
|
|
|
@ -57,6 +57,7 @@ type sqlcQuerier interface {
|
|||
GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceBuild, error)
|
||||
GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceBuild, error)
|
||||
GetLicenses(ctx context.Context) ([]License, error)
|
||||
GetLogoURL(ctx context.Context) (string, error)
|
||||
GetOrganizationByID(ctx context.Context, id uuid.UUID) (Organization, error)
|
||||
GetOrganizationByName(ctx context.Context, name string) (Organization, error)
|
||||
GetOrganizationIDsByMemberIDs(ctx context.Context, ids []uuid.UUID) ([]GetOrganizationIDsByMemberIDsRow, error)
|
||||
|
@ -146,6 +147,7 @@ type sqlcQuerier interface {
|
|||
InsertGroupMember(ctx context.Context, arg InsertGroupMemberParams) error
|
||||
InsertLicense(ctx context.Context, arg InsertLicenseParams) (License, error)
|
||||
InsertOrUpdateLastUpdateCheck(ctx context.Context, value string) error
|
||||
InsertOrUpdateLogoURL(ctx context.Context, value string) error
|
||||
InsertOrUpdateServiceBanner(ctx context.Context, value string) error
|
||||
InsertOrganization(ctx context.Context, arg InsertOrganizationParams) (Organization, error)
|
||||
InsertOrganizationMember(ctx context.Context, arg InsertOrganizationMemberParams) (OrganizationMember, error)
|
||||
|
|
|
@ -2980,6 +2980,17 @@ func (q *sqlQuerier) GetLastUpdateCheck(ctx context.Context) (string, error) {
|
|||
return value, err
|
||||
}
|
||||
|
||||
const getLogoURL = `-- name: GetLogoURL :one
|
||||
SELECT value FROM site_configs WHERE key = 'logo_url'
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) GetLogoURL(ctx context.Context) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, getLogoURL)
|
||||
var value string
|
||||
err := row.Scan(&value)
|
||||
return value, err
|
||||
}
|
||||
|
||||
const getServiceBanner = `-- name: GetServiceBanner :one
|
||||
SELECT value FROM site_configs WHERE key = 'service_banner'
|
||||
`
|
||||
|
@ -3019,6 +3030,16 @@ func (q *sqlQuerier) InsertOrUpdateLastUpdateCheck(ctx context.Context, value st
|
|||
return err
|
||||
}
|
||||
|
||||
const insertOrUpdateLogoURL = `-- name: InsertOrUpdateLogoURL :exec
|
||||
INSERT INTO site_configs (key, value) VALUES ('logo_url', $1)
|
||||
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'logo_url'
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) InsertOrUpdateLogoURL(ctx context.Context, value string) error {
|
||||
_, err := q.db.ExecContext(ctx, insertOrUpdateLogoURL, value)
|
||||
return err
|
||||
}
|
||||
|
||||
const insertOrUpdateServiceBanner = `-- name: InsertOrUpdateServiceBanner :exec
|
||||
INSERT INTO site_configs (key, value) VALUES ('service_banner', $1)
|
||||
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'service_banner'
|
||||
|
|
|
@ -23,3 +23,10 @@ ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'service_ban
|
|||
|
||||
-- name: GetServiceBanner :one
|
||||
SELECT value FROM site_configs WHERE key = 'service_banner';
|
||||
|
||||
-- name: InsertOrUpdateLogoURL :exec
|
||||
INSERT INTO site_configs (key, value) VALUES ('logo_url', $1)
|
||||
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'logo_url';
|
||||
|
||||
-- name: GetLogoURL :one
|
||||
SELECT value FROM site_configs WHERE key = 'logo_url';
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
package codersdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type AppearanceConfig struct {
|
||||
LogoURL string `json:"logo_url"`
|
||||
ServiceBanner ServiceBannerConfig `json:"service_banner"`
|
||||
}
|
||||
|
||||
type ServiceBannerConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Message string `json:"message,omitempty"`
|
||||
BackgroundColor string `json:"background_color,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) Appearance(ctx context.Context) (AppearanceConfig, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, "/api/v2/appearance", nil)
|
||||
if err != nil {
|
||||
return AppearanceConfig{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return AppearanceConfig{}, readBodyAsError(res)
|
||||
}
|
||||
var cfg AppearanceConfig
|
||||
return cfg, json.NewDecoder(res.Body).Decode(&cfg)
|
||||
}
|
||||
|
||||
func (c *Client) UpdateAppearance(ctx context.Context, appearance AppearanceConfig) error {
|
||||
res, err := c.Request(ctx, http.MethodPut, "/api/v2/appearance", appearance)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return readBodyAsError(res)
|
||||
}
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
package codersdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type UpdateBrandingRequest struct {
|
||||
LogoURL string `json:"logo_url"`
|
||||
}
|
||||
|
||||
// UpdateBranding applies customization settings available to Enterprise customers.
|
||||
func (c *Client) UpdateBranding(ctx context.Context, req UpdateBrandingRequest) error {
|
||||
res, err := c.Request(ctx, http.MethodPut, "/api/v2/branding", req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return readBodyAsError(res)
|
||||
}
|
||||
return nil
|
||||
}
|
|
@ -23,7 +23,7 @@ const (
|
|||
FeatureHighAvailability = "high_availability"
|
||||
FeatureMultipleGitAuth = "multiple_git_auth"
|
||||
FeatureExternalProvisionerDaemons = "external_provisioner_daemons"
|
||||
FeatureServiceBanners = "service_banners"
|
||||
FeatureAppearance = "appearance"
|
||||
)
|
||||
|
||||
var FeatureNames = []string{
|
||||
|
@ -35,7 +35,7 @@ var FeatureNames = []string{
|
|||
FeatureHighAvailability,
|
||||
FeatureMultipleGitAuth,
|
||||
FeatureExternalProvisionerDaemons,
|
||||
FeatureServiceBanners,
|
||||
FeatureAppearance,
|
||||
}
|
||||
|
||||
type Feature struct {
|
||||
|
|
|
@ -1,38 +0,0 @@
|
|||
package codersdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type ServiceBanner struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Message string `json:"message,omitempty"`
|
||||
BackgroundColor string `json:"background_color,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) ServiceBanner(ctx context.Context) (*ServiceBanner, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, "/api/v2/service-banner", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, readBodyAsError(res)
|
||||
}
|
||||
var b ServiceBanner
|
||||
return &b, json.NewDecoder(res.Body).Decode(&b)
|
||||
}
|
||||
|
||||
func (c *Client) SetServiceBanner(ctx context.Context, s *ServiceBanner) error {
|
||||
res, err := c.Request(ctx, http.MethodPut, "/api/v2/service-banner", s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return readBodyAsError(res)
|
||||
}
|
||||
return nil
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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"])
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -76,8 +76,8 @@ const GeneralSettingsPage = lazy(
|
|||
const SecuritySettingsPage = lazy(
|
||||
() => import("./pages/DeploySettingsPage/SecuritySettingsPage"),
|
||||
)
|
||||
const ServiceBannerSettingsPage = lazy(
|
||||
() => import("./pages/DeploySettingsPage/ServiceBannerSettingsPage"),
|
||||
const AppearanceSettingsPage = lazy(
|
||||
() => import("./pages/DeploySettingsPage/AppearanceSettingsPage"),
|
||||
)
|
||||
const UserAuthSettingsPage = lazy(
|
||||
() => import("./pages/DeploySettingsPage/UserAuthSettingsPage"),
|
||||
|
@ -345,14 +345,14 @@ export const AppRouter: FC = () => {
|
|||
}
|
||||
/>
|
||||
<Route
|
||||
path="service-banner"
|
||||
path="appearance"
|
||||
element={
|
||||
<AuthAndFrame>
|
||||
<RequirePermission
|
||||
isFeatureVisible={Boolean(permissions?.viewDeploymentConfig)}
|
||||
>
|
||||
<DeploySettingsLayout>
|
||||
<ServiceBannerSettingsPage />
|
||||
<AppearanceSettingsPage />
|
||||
</DeploySettingsLayout>
|
||||
</RequirePermission>
|
||||
</AuthAndFrame>
|
||||
|
|
|
@ -723,15 +723,15 @@ export const getFile = async (fileId: string): Promise<ArrayBuffer> => {
|
|||
return response.data
|
||||
}
|
||||
|
||||
export const getServiceBanner = async (): Promise<TypesGen.ServiceBanner> => {
|
||||
const response = await axios.get(`/api/v2/service-banner`)
|
||||
export const getAppearance = async (): Promise<TypesGen.AppearanceConfig> => {
|
||||
const response = await axios.get(`/api/v2/appearance`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const setServiceBanner = async (
|
||||
b: TypesGen.ServiceBanner,
|
||||
): Promise<TypesGen.ServiceBanner> => {
|
||||
const response = await axios.put(`/api/v2/service-banner`, b)
|
||||
export const updateAppearance = async (
|
||||
b: TypesGen.AppearanceConfig,
|
||||
): Promise<TypesGen.AppearanceConfig> => {
|
||||
const response = await axios.put(`/api/v2/appearance`, b)
|
||||
return response.data
|
||||
}
|
||||
|
||||
|
|
|
@ -23,5 +23,5 @@ export enum FeatureNames {
|
|||
SCIM = "scim",
|
||||
TemplateRBAC = "template_rbac",
|
||||
HighAvailability = "high_availability",
|
||||
ServiceBanners = "service_banners",
|
||||
Appearance = "appearance",
|
||||
}
|
||||
|
|
|
@ -31,6 +31,12 @@ export interface AgentStatsReportResponse {
|
|||
readonly tx_bytes: number
|
||||
}
|
||||
|
||||
// From codersdk/appearance.go
|
||||
export interface AppearanceConfig {
|
||||
readonly logo_url: string
|
||||
readonly service_banner: ServiceBannerConfig
|
||||
}
|
||||
|
||||
// From codersdk/roles.go
|
||||
export interface AssignableRoles extends Role {
|
||||
readonly assignable: boolean
|
||||
|
@ -611,8 +617,8 @@ export interface ServerSentEvent {
|
|||
readonly data: any
|
||||
}
|
||||
|
||||
// From codersdk/servicebanner.go
|
||||
export interface ServiceBanner {
|
||||
// From codersdk/appearance.go
|
||||
export interface ServiceBannerConfig {
|
||||
readonly enabled: boolean
|
||||
readonly message?: string
|
||||
readonly background_color?: string
|
||||
|
@ -739,6 +745,11 @@ export interface UpdateActiveTemplateVersion {
|
|||
readonly id: string
|
||||
}
|
||||
|
||||
// From codersdk/branding.go
|
||||
export interface UpdateBrandingRequest {
|
||||
readonly logo_url: string
|
||||
}
|
||||
|
||||
// From codersdk/updatecheck.go
|
||||
export interface UpdateCheckResponse {
|
||||
readonly current: boolean
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import React from "react"
|
||||
import Button from "@material-ui/core/Button"
|
||||
|
||||
export const Fieldset: React.FC<{
|
||||
children: React.ReactNode
|
||||
title: string | JSX.Element
|
||||
validation?: string | JSX.Element | false
|
||||
button?: JSX.Element | false
|
||||
onSubmit: React.FormEventHandler<HTMLFormElement>
|
||||
isSubmitting?: boolean
|
||||
}> = ({ title, children, validation, button, onSubmit, isSubmitting }) => {
|
||||
const styles = useStyles()
|
||||
|
||||
return (
|
||||
<form className={styles.fieldset} onSubmit={onSubmit}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.title}>{title}</div>
|
||||
<div className={styles.body}>{children}</div>
|
||||
</header>
|
||||
<footer className={styles.footer}>
|
||||
<div className={styles.validation}>{validation}</div>
|
||||
{button || (
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
Submit
|
||||
</Button>
|
||||
)}
|
||||
</footer>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
fieldset: {
|
||||
borderRadius: theme.spacing(1),
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
background: theme.palette.background.paper,
|
||||
marginTop: theme.spacing(4),
|
||||
},
|
||||
title: {
|
||||
...theme.typography.h5,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
body: {
|
||||
...theme.typography.body2,
|
||||
paddingTop: theme.spacing(2),
|
||||
|
||||
"& p": {
|
||||
marginTop: 0,
|
||||
marginBottom: theme.spacing(2),
|
||||
},
|
||||
},
|
||||
validation: {
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
header: {
|
||||
padding: theme.spacing(3),
|
||||
},
|
||||
footer: {
|
||||
background: theme.palette.background.paperLight,
|
||||
padding: `${theme.spacing(2)}px ${theme.spacing(3)}px`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
}))
|
|
@ -1,21 +1,14 @@
|
|||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import Brush from "@material-ui/icons/Brush"
|
||||
import LaunchOutlined from "@material-ui/icons/LaunchOutlined"
|
||||
import LockRounded from "@material-ui/icons/LockRounded"
|
||||
import Globe from "@material-ui/icons/Public"
|
||||
import VpnKeyOutlined from "@material-ui/icons/VpnKeyOutlined"
|
||||
import Info from "@material-ui/icons/Info"
|
||||
import { useSelector } from "@xstate/react"
|
||||
import { GitIcon } from "components/Icons/GitIcon"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import React, {
|
||||
ElementType,
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
useContext,
|
||||
} from "react"
|
||||
import React, { ElementType, PropsWithChildren, ReactNode } from "react"
|
||||
import { NavLink } from "react-router-dom"
|
||||
import { combineClasses } from "util/combineClasses"
|
||||
import { XServiceContext } from "../../xServices/StateContext"
|
||||
|
||||
const SidebarNavItem: React.FC<
|
||||
PropsWithChildren<{ href: string; icon: ReactNode }>
|
||||
|
@ -48,11 +41,6 @@ const SidebarNavItemIcon: React.FC<{ icon: ElementType }> = ({
|
|||
|
||||
export const Sidebar: React.FC = () => {
|
||||
const styles = useStyles()
|
||||
const xServices = useContext(XServiceContext)
|
||||
const experimental = useSelector(
|
||||
xServices.entitlementsXService,
|
||||
(state) => state.context.entitlements.experimental,
|
||||
)
|
||||
|
||||
return (
|
||||
<nav className={styles.sidebar}>
|
||||
|
@ -62,20 +50,24 @@ export const Sidebar: React.FC = () => {
|
|||
>
|
||||
General
|
||||
</SidebarNavItem>
|
||||
<SidebarNavItem
|
||||
href="../appearance"
|
||||
icon={<SidebarNavItemIcon icon={Brush} />}
|
||||
>
|
||||
Appearance
|
||||
</SidebarNavItem>
|
||||
<SidebarNavItem
|
||||
href="../userauth"
|
||||
icon={<SidebarNavItemIcon icon={VpnKeyOutlined} />}
|
||||
>
|
||||
User Authentication
|
||||
</SidebarNavItem>
|
||||
{experimental && (
|
||||
<SidebarNavItem
|
||||
href="../gitauth"
|
||||
icon={<SidebarNavItemIcon icon={GitIcon} />}
|
||||
>
|
||||
Git Authentication
|
||||
</SidebarNavItem>
|
||||
)}
|
||||
<SidebarNavItem
|
||||
href="../gitauth"
|
||||
icon={<SidebarNavItemIcon icon={GitIcon} />}
|
||||
>
|
||||
Git Authentication
|
||||
</SidebarNavItem>
|
||||
<SidebarNavItem
|
||||
href="../network"
|
||||
icon={<SidebarNavItemIcon icon={Globe} />}
|
||||
|
@ -88,12 +80,6 @@ export const Sidebar: React.FC = () => {
|
|||
>
|
||||
Security
|
||||
</SidebarNavItem>
|
||||
<SidebarNavItem
|
||||
href="../service-banner"
|
||||
icon={<SidebarNavItemIcon icon={Info} />}
|
||||
>
|
||||
Service Banner
|
||||
</SidebarNavItem>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import { NavbarView } from "../NavbarView/NavbarView"
|
|||
|
||||
export const Navbar: React.FC = () => {
|
||||
const xServices = useContext(XServiceContext)
|
||||
const [appearanceState] = useActor(xServices.appearanceXService)
|
||||
const [authState, authSend] = useActor(xServices.authXService)
|
||||
const [buildInfoState] = useActor(xServices.buildInfoXService)
|
||||
const { me, permissions } = authState.context
|
||||
|
@ -24,6 +25,7 @@ export const Navbar: React.FC = () => {
|
|||
return (
|
||||
<NavbarView
|
||||
user={me}
|
||||
logo_url={appearanceState.context.appearance.logo_url}
|
||||
buildInfo={buildInfoState.context.buildInfo}
|
||||
onSignOut={onSignOut}
|
||||
canViewAuditLog={canViewAuditLog}
|
||||
|
|
|
@ -14,6 +14,7 @@ import { Logo } from "../Icons/Logo"
|
|||
import { UserDropdown } from "../UserDropdown/UsersDropdown"
|
||||
|
||||
export interface NavbarViewProps {
|
||||
logo_url?: string
|
||||
user?: TypesGen.User
|
||||
buildInfo?: TypesGen.BuildInfoResponse
|
||||
onSignOut: () => void
|
||||
|
@ -84,6 +85,7 @@ const NavItems: React.FC<
|
|||
}
|
||||
export const NavbarView: React.FC<React.PropsWithChildren<NavbarViewProps>> = ({
|
||||
user,
|
||||
logo_url,
|
||||
buildInfo,
|
||||
onSignOut,
|
||||
canViewAuditLog,
|
||||
|
@ -112,7 +114,11 @@ export const NavbarView: React.FC<React.PropsWithChildren<NavbarViewProps>> = ({
|
|||
>
|
||||
<div className={styles.drawer}>
|
||||
<div className={styles.drawerHeader}>
|
||||
<Logo fill="white" opacity={1} width={125} />
|
||||
{logo_url ? (
|
||||
<img src={logo_url} alt="Custom Logo" />
|
||||
) : (
|
||||
<Logo fill="white" opacity={1} width={125} />
|
||||
)}
|
||||
</div>
|
||||
<NavItems
|
||||
canViewAuditLog={canViewAuditLog}
|
||||
|
@ -122,7 +128,11 @@ export const NavbarView: React.FC<React.PropsWithChildren<NavbarViewProps>> = ({
|
|||
</Drawer>
|
||||
|
||||
<NavLink className={styles.logo} to="/workspaces">
|
||||
<Logo fill="white" opacity={1} width={125} />
|
||||
{logo_url ? (
|
||||
<img src={logo_url} alt="Custom Logo" />
|
||||
) : (
|
||||
<Logo fill="white" opacity={1} width={125} />
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
<NavItems
|
||||
|
@ -199,8 +209,10 @@ const useStyles = makeStyles((theme) => ({
|
|||
display: "flex",
|
||||
height: navHeight,
|
||||
paddingRight: theme.spacing(4),
|
||||
"& svg": {
|
||||
// svg is for the Coder logo, img is for custom images
|
||||
"& svg, & img": {
|
||||
width: 109,
|
||||
maxHeight: `calc(${navHeight}px)`,
|
||||
},
|
||||
},
|
||||
title: {
|
||||
|
|
|
@ -1,20 +1,14 @@
|
|||
import { useActor } from "@xstate/react"
|
||||
import { useContext, useEffect } from "react"
|
||||
import { useContext } from "react"
|
||||
import { XServiceContext } from "xServices/StateContext"
|
||||
import { ServiceBannerView } from "./ServiceBannerView"
|
||||
|
||||
export const ServiceBanner: React.FC = () => {
|
||||
const xServices = useContext(XServiceContext)
|
||||
const [serviceBannerState, serviceBannerSend] = useActor(
|
||||
xServices.serviceBannerXService,
|
||||
)
|
||||
const [appearanceState] = useActor(xServices.appearanceXService)
|
||||
|
||||
const { message, background_color, enabled } =
|
||||
serviceBannerState.context.serviceBanner
|
||||
|
||||
useEffect(() => {
|
||||
serviceBannerSend("GET_BANNER")
|
||||
}, [serviceBannerSend])
|
||||
appearanceState.context.appearance.service_banner
|
||||
|
||||
if (!enabled) {
|
||||
return null
|
||||
|
@ -25,7 +19,7 @@ export const ServiceBanner: React.FC = () => {
|
|||
<ServiceBannerView
|
||||
message={message}
|
||||
backgroundColor={background_color}
|
||||
preview={serviceBannerState.context.preview}
|
||||
preview={appearanceState.context.preview}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
|
|
|
@ -13,7 +13,7 @@ import templateVersionPage from "./templateVersionPage.json"
|
|||
import loginPage from "./loginPage.json"
|
||||
import workspaceChangeVersionPage from "./workspaceChangeVersionPage.json"
|
||||
import workspaceSchedulePage from "./workspaceSchedulePage.json"
|
||||
import serviceBannerSettings from "./serviceBannerSettings.json"
|
||||
import appearanceSettings from "./appearanceSettings.json"
|
||||
import starterTemplatesPage from "./starterTemplatesPage.json"
|
||||
import starterTemplatePage from "./starterTemplatePage.json"
|
||||
import createTemplatePage from "./createTemplatePage.json"
|
||||
|
@ -34,7 +34,7 @@ export const en = {
|
|||
loginPage,
|
||||
workspaceChangeVersionPage,
|
||||
workspaceSchedulePage,
|
||||
serviceBannerSettings,
|
||||
appearanceSettings,
|
||||
starterTemplatesPage,
|
||||
starterTemplatePage,
|
||||
createTemplatePage,
|
||||
|
|
|
@ -0,0 +1,278 @@
|
|||
import Button from "@material-ui/core/Button"
|
||||
import FormControlLabel from "@material-ui/core/FormControlLabel"
|
||||
import FormHelperText from "@material-ui/core/FormHelperText"
|
||||
import InputAdornment from "@material-ui/core/InputAdornment"
|
||||
import { useTheme } from "@material-ui/core/styles"
|
||||
import makeStyles from "@material-ui/core/styles/makeStyles"
|
||||
import Switch from "@material-ui/core/Switch"
|
||||
import TextField from "@material-ui/core/TextField"
|
||||
import { useActor } from "@xstate/react"
|
||||
import { FeatureNames } from "api/types"
|
||||
import { AppearanceConfig } from "api/typesGenerated"
|
||||
import {
|
||||
Badges,
|
||||
DisabledBadge,
|
||||
EnterpriseBadge,
|
||||
EntitledBadge,
|
||||
} from "components/DeploySettingsLayout/Badges"
|
||||
import { Fieldset } from "components/DeploySettingsLayout/Fieldset"
|
||||
import { Header } from "components/DeploySettingsLayout/Header"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import { useFormik } from "formik"
|
||||
import React, { useContext, useState } from "react"
|
||||
import { BlockPicker } from "react-color"
|
||||
import { Helmet } from "react-helmet-async"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { getFormHelpers } from "util/formUtils"
|
||||
import { pageTitle } from "util/page"
|
||||
import { XServiceContext } from "xServices/StateContext"
|
||||
|
||||
// ServiceBanner is unlike the other Deployment Settings pages because it
|
||||
// implements a form, whereas the others are read-only. We make this
|
||||
// exception because the Service Banner is visual, and configuring it from
|
||||
// the command line would be a significantly worse user experience.
|
||||
const AppearanceSettingsPage: React.FC = () => {
|
||||
const xServices = useContext(XServiceContext)
|
||||
const [appearanceXService, appearanceSend] = useActor(
|
||||
xServices.appearanceXService,
|
||||
)
|
||||
const [entitlementsState] = useActor(xServices.entitlementsXService)
|
||||
const appearance = appearanceXService.context.appearance
|
||||
const styles = useStyles()
|
||||
|
||||
const isEntitled =
|
||||
entitlementsState.context.entitlements.features[FeatureNames.Appearance]
|
||||
.entitlement !== "not_entitled"
|
||||
|
||||
const updateAppearance = (
|
||||
newConfig: Partial<AppearanceConfig>,
|
||||
preview: boolean,
|
||||
) => {
|
||||
const newAppearance = {
|
||||
...appearance,
|
||||
...newConfig,
|
||||
}
|
||||
if (preview) {
|
||||
appearanceSend({
|
||||
type: "SET_PREVIEW_APPEARANCE",
|
||||
appearance: newAppearance,
|
||||
})
|
||||
return
|
||||
}
|
||||
appearanceSend({
|
||||
type: "SET_APPEARANCE",
|
||||
appearance: newAppearance,
|
||||
})
|
||||
}
|
||||
|
||||
const logoForm = useFormik<{
|
||||
logo_url: string
|
||||
}>({
|
||||
initialValues: {
|
||||
logo_url: appearance.logo_url,
|
||||
},
|
||||
onSubmit: (values) => updateAppearance(values, false),
|
||||
})
|
||||
const logoFieldHelpers = getFormHelpers(logoForm)
|
||||
|
||||
const serviceBannerForm = useFormik<AppearanceConfig["service_banner"]>({
|
||||
initialValues: {
|
||||
message: appearance.service_banner.message,
|
||||
enabled: appearance.service_banner.enabled,
|
||||
background_color: appearance.service_banner.background_color,
|
||||
},
|
||||
onSubmit: (values) =>
|
||||
updateAppearance(
|
||||
{
|
||||
service_banner: values,
|
||||
},
|
||||
false,
|
||||
),
|
||||
})
|
||||
const serviceBannerFieldHelpers = getFormHelpers(serviceBannerForm)
|
||||
const [backgroundColor, setBackgroundColor] = useState(
|
||||
serviceBannerForm.values.background_color,
|
||||
)
|
||||
|
||||
const theme = useTheme()
|
||||
const [t] = useTranslation("appearanceSettings")
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{pageTitle("Appearance Settings")}</title>
|
||||
</Helmet>
|
||||
|
||||
<Header
|
||||
title="Appearance"
|
||||
description="Customize the look and feel of your Coder deployment."
|
||||
/>
|
||||
|
||||
<Badges>
|
||||
{isEntitled ? <EntitledBadge /> : <DisabledBadge />}
|
||||
<EnterpriseBadge />
|
||||
</Badges>
|
||||
|
||||
<Fieldset
|
||||
title="Logo URL"
|
||||
validation="We recommend a transparent image with 3:1 aspect ratio."
|
||||
onSubmit={logoForm.handleSubmit}
|
||||
>
|
||||
<p>
|
||||
Specify a custom URL for your logo to be displayed in the top left
|
||||
corner of the dashboard.
|
||||
</p>
|
||||
<TextField
|
||||
{...logoFieldHelpers("logo_url")}
|
||||
defaultValue={appearance.logo_url}
|
||||
fullWidth
|
||||
placeholder="Leave empty to display the Coder logo."
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end" className={styles.logoAdornment}>
|
||||
<img
|
||||
alt=""
|
||||
src={logoForm.values.logo_url}
|
||||
// This prevent browser to display the ugly error icon if the
|
||||
// image path is wrong or user didn't finish typing the url
|
||||
onError={(e) => (e.currentTarget.style.display = "none")}
|
||||
onLoad={(e) => (e.currentTarget.style.display = "inline")}
|
||||
/>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Fieldset>
|
||||
|
||||
<Fieldset
|
||||
title="Service Banner"
|
||||
onSubmit={serviceBannerForm.handleSubmit}
|
||||
button={
|
||||
!isEntitled && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
updateAppearance(
|
||||
{
|
||||
service_banner: {
|
||||
message:
|
||||
"👋 **This** is a service banner. The banner's color and text are editable.",
|
||||
background_color: "#004852",
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
}}
|
||||
>
|
||||
{t("showPreviewLabel")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
validation={
|
||||
!isEntitled && (
|
||||
<p>
|
||||
Your license does not include Service Banners.{" "}
|
||||
<a href="mailto:sales@coder.com">Contact sales</a> to learn more.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
>
|
||||
<p>Configure a banner that displays a message to all users.</p>
|
||||
|
||||
{isEntitled && (
|
||||
<Stack>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
color="primary"
|
||||
checked={serviceBannerForm.values.enabled}
|
||||
onChange={async () => {
|
||||
const newState = !serviceBannerForm.values.enabled
|
||||
const newBanner = {
|
||||
...serviceBannerForm.values,
|
||||
enabled: newState,
|
||||
}
|
||||
updateAppearance(
|
||||
{
|
||||
service_banner: newBanner,
|
||||
},
|
||||
false,
|
||||
)
|
||||
await serviceBannerForm.setFieldValue("enabled", newState)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Enabled"
|
||||
/>
|
||||
<Stack spacing={0}>
|
||||
<TextField
|
||||
{...serviceBannerFieldHelpers("message")}
|
||||
fullWidth
|
||||
label="Message"
|
||||
variant="outlined"
|
||||
multiline
|
||||
/>
|
||||
<FormHelperText>{t("messageHelperText")}</FormHelperText>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={0}>
|
||||
<h3>{"Background Color"}</h3>
|
||||
<BlockPicker
|
||||
color={backgroundColor}
|
||||
onChange={async (color) => {
|
||||
setBackgroundColor(color.hex)
|
||||
await serviceBannerForm.setFieldValue(
|
||||
"background_color",
|
||||
color.hex,
|
||||
)
|
||||
updateAppearance(
|
||||
{
|
||||
service_banner: {
|
||||
...serviceBannerForm.values,
|
||||
background_color: color.hex,
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
}}
|
||||
triangle="hide"
|
||||
colors={["#004852", "#D65D0F", "#4CD473", "#D94A5D", "#5A00CF"]}
|
||||
styles={{
|
||||
default: {
|
||||
input: {
|
||||
color: "white",
|
||||
backgroundColor: theme.palette.background.default,
|
||||
},
|
||||
body: {
|
||||
backgroundColor: "black",
|
||||
color: "white",
|
||||
},
|
||||
card: {
|
||||
backgroundColor: "black",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</Fieldset>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
form: {
|
||||
maxWidth: "500px",
|
||||
},
|
||||
logoAdornment: {
|
||||
width: theme.spacing(3),
|
||||
height: theme.spacing(3),
|
||||
|
||||
"& img": {
|
||||
maxWidth: "100%",
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
export default AppearanceSettingsPage
|
|
@ -1,231 +0,0 @@
|
|||
import TextField from "@material-ui/core/TextField"
|
||||
import { useActor } from "@xstate/react"
|
||||
import { FeatureNames } from "api/types"
|
||||
import {
|
||||
Badges,
|
||||
DisabledBadge,
|
||||
EnterpriseBadge,
|
||||
EntitledBadge,
|
||||
} from "components/DeploySettingsLayout/Badges"
|
||||
import { Header } from "components/DeploySettingsLayout/Header"
|
||||
import { LoadingButton } from "components/LoadingButton/LoadingButton"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import { FormikContextType, useFormik } from "formik"
|
||||
import React, { useContext, useState } from "react"
|
||||
import { Helmet } from "react-helmet-async"
|
||||
import { pageTitle } from "util/page"
|
||||
import * as Yup from "yup"
|
||||
import { XServiceContext } from "xServices/StateContext"
|
||||
import { getFormHelpers } from "util/formUtils"
|
||||
import makeStyles from "@material-ui/core/styles/makeStyles"
|
||||
import FormControlLabel from "@material-ui/core/FormControlLabel"
|
||||
import Switch from "@material-ui/core/Switch"
|
||||
import { BlockPicker } from "react-color"
|
||||
import { useTheme } from "@material-ui/core/styles"
|
||||
import FormHelperText from "@material-ui/core/FormHelperText"
|
||||
import Button from "@material-ui/core/Button"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
export interface ServiceBannerFormValues {
|
||||
message?: string
|
||||
backgroundColor?: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
// TODO:
|
||||
const validationSchema = Yup.object({})
|
||||
|
||||
// ServiceBanner is unlike the other Deployment Settings pages because it
|
||||
// implements a form, whereas the others are read-only. We make this
|
||||
// exception because the Service Banner is visual, and configuring it from
|
||||
// the command line would be a significantly worse user experience.
|
||||
const ServiceBannerSettingsPage: React.FC = () => {
|
||||
const xServices = useContext(XServiceContext)
|
||||
const [serviceBannerState, serviceBannerSend] = useActor(
|
||||
xServices.serviceBannerXService,
|
||||
)
|
||||
|
||||
const [entitlementsState] = useActor(xServices.entitlementsXService)
|
||||
|
||||
const serviceBanner = serviceBannerState.context.serviceBanner
|
||||
|
||||
const styles = useStyles()
|
||||
|
||||
const isEntitled =
|
||||
entitlementsState.context.entitlements.features[FeatureNames.ServiceBanners]
|
||||
.entitlement !== "not_entitled"
|
||||
|
||||
const setBanner = (values: ServiceBannerFormValues, preview: boolean) => {
|
||||
const newBanner = {
|
||||
message: values.message,
|
||||
enabled: values.enabled,
|
||||
background_color: values.backgroundColor,
|
||||
}
|
||||
if (preview) {
|
||||
serviceBannerSend({
|
||||
type: "SET_PREVIEW_BANNER",
|
||||
serviceBanner: newBanner,
|
||||
})
|
||||
return
|
||||
}
|
||||
serviceBannerSend({
|
||||
type: "SET_BANNER",
|
||||
serviceBanner: newBanner,
|
||||
})
|
||||
}
|
||||
|
||||
const initialValues: ServiceBannerFormValues = {
|
||||
message: serviceBanner.message,
|
||||
enabled: serviceBanner.enabled,
|
||||
backgroundColor: serviceBanner.background_color,
|
||||
}
|
||||
|
||||
const form: FormikContextType<ServiceBannerFormValues> =
|
||||
useFormik<ServiceBannerFormValues>({
|
||||
initialValues,
|
||||
validationSchema,
|
||||
onSubmit: (values) => setBanner(values, false),
|
||||
})
|
||||
const getFieldHelpers = getFormHelpers<ServiceBannerFormValues>(form)
|
||||
|
||||
const [backgroundColor, setBackgroundColor] = useState(
|
||||
form.values.backgroundColor,
|
||||
)
|
||||
|
||||
const theme = useTheme()
|
||||
const [t] = useTranslation("serviceBannerSettings")
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{pageTitle("Service Banner Settings")}</title>
|
||||
</Helmet>
|
||||
|
||||
<Header
|
||||
title="Service Banner"
|
||||
description="Configure a banner that displays a message to all users"
|
||||
docsHref="https://coder.com/docs/coder-oss/latest/admin/service-banners"
|
||||
/>
|
||||
<Badges>
|
||||
{isEntitled ? <EntitledBadge /> : <DisabledBadge />}
|
||||
<EnterpriseBadge />
|
||||
</Badges>
|
||||
|
||||
{isEntitled ? (
|
||||
<form className={styles.form} onSubmit={form.handleSubmit}>
|
||||
<Stack>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
color="primary"
|
||||
checked={form.values.enabled}
|
||||
onChange={() => {
|
||||
const newState = !form.values.enabled
|
||||
const newBanner = {
|
||||
...form.values,
|
||||
enabled: newState,
|
||||
}
|
||||
setBanner(newBanner, false)
|
||||
form.setFieldValue("enabled", newState)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Enabled"
|
||||
/>
|
||||
<Stack spacing={0}>
|
||||
<TextField
|
||||
{...getFieldHelpers("message")}
|
||||
fullWidth
|
||||
label="Message"
|
||||
variant="outlined"
|
||||
multiline
|
||||
onChange={(e) => {
|
||||
form.setFieldValue("message", e.target.value)
|
||||
setBanner(
|
||||
{
|
||||
...form.values,
|
||||
message: e.target.value,
|
||||
},
|
||||
true,
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<FormHelperText>{t("messageHelperText")}</FormHelperText>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={0}>
|
||||
<h3>{"Background Color"}</h3>
|
||||
<BlockPicker
|
||||
color={backgroundColor}
|
||||
onChange={(color) => {
|
||||
setBackgroundColor(color.hex)
|
||||
form.setFieldValue("backgroundColor", color.hex)
|
||||
setBanner(
|
||||
{
|
||||
...form.values,
|
||||
backgroundColor: color.hex,
|
||||
},
|
||||
true,
|
||||
)
|
||||
}}
|
||||
triangle="hide"
|
||||
colors={["#004852", "#D65D0F", "#4CD473", "#D94A5D", "#5A00CF"]}
|
||||
styles={{
|
||||
default: {
|
||||
input: {
|
||||
color: "white",
|
||||
backgroundColor: theme.palette.background.default,
|
||||
},
|
||||
body: {
|
||||
backgroundColor: "black",
|
||||
color: "white",
|
||||
},
|
||||
card: {
|
||||
backgroundColor: "black",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row">
|
||||
<LoadingButton loading={false} type="submit" variant="contained">
|
||||
{t("updateLabel")}
|
||||
</LoadingButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
Your license does not include Service Banners.{" "}
|
||||
<a href="mailto:sales@coder.com">Contact sales</a> to learn more.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setBanner(
|
||||
{
|
||||
message:
|
||||
"👋 **This** is a service banner. The banner's color and text are editable.",
|
||||
backgroundColor: "#004852",
|
||||
enabled: true,
|
||||
},
|
||||
true,
|
||||
)
|
||||
}}
|
||||
>
|
||||
{t("showPreviewLabel")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
form: {
|
||||
maxWidth: "500px",
|
||||
},
|
||||
}))
|
||||
|
||||
export default ServiceBannerSettingsPage
|
|
@ -1097,3 +1097,10 @@ export const MockPermissions: Permissions = {
|
|||
viewAuditLog: true,
|
||||
viewDeploymentConfig: true,
|
||||
}
|
||||
|
||||
export const MockAppearance: TypesGen.AppearanceConfig = {
|
||||
logo_url: "",
|
||||
service_banner: {
|
||||
enabled: false,
|
||||
},
|
||||
}
|
||||
|
|
|
@ -282,4 +282,8 @@ export const handlers = [
|
|||
rest.get("/api/v2/workspace-quota/:userId", (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json(MockWorkspaceQuota))
|
||||
}),
|
||||
|
||||
rest.get("/api/v2/appearance", (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json(M.MockAppearance))
|
||||
}),
|
||||
]
|
||||
|
|
|
@ -7,13 +7,13 @@ import { updateCheckMachine } from "./updateCheck/updateCheckXService"
|
|||
import { deploymentConfigMachine } from "./deploymentConfig/deploymentConfigMachine"
|
||||
import { entitlementsMachine } from "./entitlements/entitlementsXService"
|
||||
import { siteRolesMachine } from "./roles/siteRolesXService"
|
||||
import { serviceBannerMachine } from "./serviceBanner/serviceBannerXService"
|
||||
import { appearanceMachine } from "./appearance/appearanceXService"
|
||||
|
||||
interface XServiceContextType {
|
||||
authXService: ActorRefFrom<typeof authMachine>
|
||||
buildInfoXService: ActorRefFrom<typeof buildInfoMachine>
|
||||
entitlementsXService: ActorRefFrom<typeof entitlementsMachine>
|
||||
serviceBannerXService: ActorRefFrom<typeof serviceBannerMachine>
|
||||
appearanceXService: ActorRefFrom<typeof appearanceMachine>
|
||||
siteRolesXService: ActorRefFrom<typeof siteRolesMachine>
|
||||
// Since the info here is used by multiple deployment settings page and we don't want to refetch them every time
|
||||
deploymentConfigXService: ActorRefFrom<typeof deploymentConfigMachine>
|
||||
|
@ -37,7 +37,7 @@ export const XServiceProvider: FC<{ children: ReactNode }> = ({ children }) => {
|
|||
authXService: useInterpret(authMachine),
|
||||
buildInfoXService: useInterpret(buildInfoMachine),
|
||||
entitlementsXService: useInterpret(entitlementsMachine),
|
||||
serviceBannerXService: useInterpret(serviceBannerMachine),
|
||||
appearanceXService: useInterpret(appearanceMachine),
|
||||
siteRolesXService: useInterpret(siteRolesMachine),
|
||||
deploymentConfigXService: useInterpret(deploymentConfigMachine),
|
||||
updateCheckXService: useInterpret(updateCheckMachine),
|
||||
|
|
|
@ -0,0 +1,137 @@
|
|||
import { displaySuccess } from "components/GlobalSnackbar/utils"
|
||||
import { assign, createMachine } from "xstate"
|
||||
import * as API from "../../api/api"
|
||||
import { AppearanceConfig } from "../../api/typesGenerated"
|
||||
|
||||
export const Language = {
|
||||
getAppearanceError: "Error getting appearance.",
|
||||
setAppearanceError: "Error setting appearance.",
|
||||
}
|
||||
|
||||
export type AppearanceContext = {
|
||||
appearance: AppearanceConfig
|
||||
getAppearanceError?: Error | unknown
|
||||
setAppearanceError?: Error | unknown
|
||||
preview: boolean
|
||||
}
|
||||
|
||||
export type AppearanceEvent =
|
||||
| {
|
||||
type: "GET_APPEARANCE"
|
||||
}
|
||||
| { type: "SET_PREVIEW_APPEARANCE"; appearance: AppearanceConfig }
|
||||
| { type: "SET_APPEARANCE"; appearance: AppearanceConfig }
|
||||
|
||||
const emptyAppearance: AppearanceConfig = {
|
||||
logo_url: "",
|
||||
service_banner: {
|
||||
enabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const appearanceMachine = createMachine(
|
||||
{
|
||||
id: "appearanceMachine",
|
||||
predictableActionArguments: true,
|
||||
tsTypes: {} as import("./appearanceXService.typegen").Typegen0,
|
||||
schema: {
|
||||
context: {} as AppearanceContext,
|
||||
events: {} as AppearanceEvent,
|
||||
services: {
|
||||
getAppearance: {
|
||||
data: {} as AppearanceConfig,
|
||||
},
|
||||
setAppearance: {
|
||||
data: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
context: {
|
||||
appearance: emptyAppearance,
|
||||
preview: false,
|
||||
},
|
||||
initial: "gettingAppearance",
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
GET_APPEARANCE: "gettingAppearance",
|
||||
SET_PREVIEW_APPEARANCE: "settingPreviewAppearance",
|
||||
SET_APPEARANCE: "settingAppearance",
|
||||
},
|
||||
},
|
||||
gettingAppearance: {
|
||||
entry: "clearGetAppearanceError",
|
||||
invoke: {
|
||||
id: "getAppearance",
|
||||
src: "getAppearance",
|
||||
onDone: {
|
||||
target: "idle",
|
||||
actions: ["assignAppearance"],
|
||||
},
|
||||
onError: {
|
||||
target: "idle",
|
||||
actions: ["assignGetAppearanceError"],
|
||||
},
|
||||
},
|
||||
},
|
||||
settingPreviewAppearance: {
|
||||
entry: [
|
||||
"clearGetAppearanceError",
|
||||
"clearSetAppearanceError",
|
||||
"assignPreviewAppearance",
|
||||
],
|
||||
always: {
|
||||
target: "idle",
|
||||
},
|
||||
},
|
||||
settingAppearance: {
|
||||
entry: "clearSetAppearanceError",
|
||||
invoke: {
|
||||
id: "setAppearance",
|
||||
src: "setAppearance",
|
||||
onDone: {
|
||||
target: "idle",
|
||||
actions: ["assignAppearance", "notifyUpdateAppearanceSuccess"],
|
||||
},
|
||||
onError: {
|
||||
target: "idle",
|
||||
actions: ["assignSetAppearanceError"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
assignPreviewAppearance: assign({
|
||||
appearance: (_, event) => event.appearance,
|
||||
// The xState docs suggest that we can use a static value, but I failed
|
||||
// to find a way to do that that doesn't generate type errors.
|
||||
preview: (_, __) => true,
|
||||
}),
|
||||
notifyUpdateAppearanceSuccess: () => {
|
||||
displaySuccess("Successfully updated appearance settings!")
|
||||
},
|
||||
assignAppearance: assign({
|
||||
appearance: (_, event) => event.data as AppearanceConfig,
|
||||
preview: (_, __) => false,
|
||||
}),
|
||||
assignGetAppearanceError: assign({
|
||||
getAppearanceError: (_, event) => event.data,
|
||||
}),
|
||||
clearGetAppearanceError: assign({
|
||||
getAppearanceError: (_) => undefined,
|
||||
}),
|
||||
assignSetAppearanceError: assign({
|
||||
setAppearanceError: (_, event) => event.data,
|
||||
}),
|
||||
clearSetAppearanceError: assign({
|
||||
setAppearanceError: (_) => undefined,
|
||||
}),
|
||||
},
|
||||
services: {
|
||||
getAppearance: API.getAppearance,
|
||||
setAppearance: (_, event) => API.updateAppearance(event.appearance),
|
||||
},
|
||||
},
|
||||
)
|
|
@ -1,134 +0,0 @@
|
|||
import { displaySuccess } from "components/GlobalSnackbar/utils"
|
||||
import { assign, createMachine } from "xstate"
|
||||
import * as API from "../../api/api"
|
||||
import { ServiceBanner } from "../../api/typesGenerated"
|
||||
|
||||
export const Language = {
|
||||
getServiceBannerError: "Error getting service banner.",
|
||||
setServiceBannerError: "Error setting service banner.",
|
||||
}
|
||||
|
||||
export type ServiceBannerContext = {
|
||||
serviceBanner: ServiceBanner
|
||||
getServiceBannerError?: Error | unknown
|
||||
setServiceBannerError?: Error | unknown
|
||||
preview: boolean
|
||||
}
|
||||
|
||||
export type ServiceBannerEvent =
|
||||
| {
|
||||
type: "GET_BANNER"
|
||||
}
|
||||
| { type: "SET_PREVIEW_BANNER"; serviceBanner: ServiceBanner }
|
||||
| { type: "SET_BANNER"; serviceBanner: ServiceBanner }
|
||||
|
||||
const emptyBanner = {
|
||||
enabled: false,
|
||||
}
|
||||
|
||||
export const serviceBannerMachine = createMachine(
|
||||
{
|
||||
id: "serviceBannerMachine",
|
||||
predictableActionArguments: true,
|
||||
tsTypes: {} as import("./serviceBannerXService.typegen").Typegen0,
|
||||
schema: {
|
||||
context: {} as ServiceBannerContext,
|
||||
events: {} as ServiceBannerEvent,
|
||||
services: {
|
||||
getServiceBanner: {
|
||||
data: {} as ServiceBanner,
|
||||
},
|
||||
setServiceBanner: {
|
||||
data: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
context: {
|
||||
serviceBanner: emptyBanner,
|
||||
preview: false,
|
||||
},
|
||||
initial: "idle",
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
GET_BANNER: "gettingBanner",
|
||||
SET_PREVIEW_BANNER: "settingPreviewBanner",
|
||||
SET_BANNER: "settingBanner",
|
||||
},
|
||||
},
|
||||
gettingBanner: {
|
||||
entry: "clearGetBannerError",
|
||||
invoke: {
|
||||
id: "getBanner",
|
||||
src: "getBanner",
|
||||
onDone: {
|
||||
target: "idle",
|
||||
actions: ["assignBanner"],
|
||||
},
|
||||
onError: {
|
||||
target: "idle",
|
||||
actions: ["assignGetBannerError"],
|
||||
},
|
||||
},
|
||||
},
|
||||
settingPreviewBanner: {
|
||||
entry: [
|
||||
"clearGetBannerError",
|
||||
"clearSetBannerError",
|
||||
"assignPreviewBanner",
|
||||
],
|
||||
always: {
|
||||
target: "idle",
|
||||
},
|
||||
},
|
||||
settingBanner: {
|
||||
entry: "clearSetBannerError",
|
||||
invoke: {
|
||||
id: "setBanner",
|
||||
src: "setBanner",
|
||||
onDone: {
|
||||
target: "idle",
|
||||
actions: ["assignBanner", "notifyUpdateBannerSuccess"],
|
||||
},
|
||||
onError: {
|
||||
target: "idle",
|
||||
actions: ["assignSetBannerError"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
assignPreviewBanner: assign({
|
||||
serviceBanner: (_, event) => event.serviceBanner,
|
||||
// The xState docs suggest that we can use a static value, but I failed
|
||||
// to find a way to do that that doesn't generate type errors.
|
||||
preview: (_, __) => true,
|
||||
}),
|
||||
notifyUpdateBannerSuccess: () => {
|
||||
displaySuccess("Successfully updated Service Banner!")
|
||||
},
|
||||
assignBanner: assign({
|
||||
serviceBanner: (_, event) => event.data as ServiceBanner,
|
||||
preview: (_, __) => false,
|
||||
}),
|
||||
assignGetBannerError: assign({
|
||||
getServiceBannerError: (_, event) => event.data,
|
||||
}),
|
||||
clearGetBannerError: assign({
|
||||
getServiceBannerError: (_) => undefined,
|
||||
}),
|
||||
assignSetBannerError: assign({
|
||||
setServiceBannerError: (_, event) => event.data,
|
||||
}),
|
||||
clearSetBannerError: assign({
|
||||
setServiceBannerError: (_) => undefined,
|
||||
}),
|
||||
},
|
||||
services: {
|
||||
getBanner: API.getServiceBanner,
|
||||
setBanner: (_, event) => API.setServiceBanner(event.serviceBanner),
|
||||
},
|
||||
},
|
||||
)
|
Loading…
Reference in New Issue