diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index dc09ad3072..387e1d96af 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -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) { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index f25e085a38..f54539a967 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -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) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index bf219b4f58..339ca02471 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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' diff --git a/coderd/database/queries/siteconfig.sql b/coderd/database/queries/siteconfig.sql index 3962110f14..9b6f47185f 100644 --- a/coderd/database/queries/siteconfig.sql +++ b/coderd/database/queries/siteconfig.sql @@ -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'; diff --git a/codersdk/appearance.go b/codersdk/appearance.go new file mode 100644 index 0000000000..07b69e67dc --- /dev/null +++ b/codersdk/appearance.go @@ -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 +} diff --git a/codersdk/branding.go b/codersdk/branding.go new file mode 100644 index 0000000000..fc24303511 --- /dev/null +++ b/codersdk/branding.go @@ -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 +} diff --git a/codersdk/features.go b/codersdk/features.go index 6469aaa35f..58fff94e0a 100644 --- a/codersdk/features.go +++ b/codersdk/features.go @@ -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 { diff --git a/codersdk/servicebanner.go b/codersdk/servicebanner.go deleted file mode 100644 index 8b56e3abf9..0000000000 --- a/codersdk/servicebanner.go +++ /dev/null @@ -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 -} diff --git a/enterprise/coderd/appearance.go b/enterprise/coderd/appearance.go new file mode 100644 index 0000000000..ab7a9cde1b --- /dev/null +++ b/enterprise/coderd/appearance.go @@ -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) +} diff --git a/enterprise/coderd/servicebanner_test.go b/enterprise/coderd/appearance_test.go similarity index 68% rename from enterprise/coderd/servicebanner_test.go rename to enterprise/coderd/appearance_test.go index 39ba8bd158..c83f9fb620 100644 --- a/enterprise/coderd/servicebanner_test.go +++ b/enterprise/coderd/appearance_test.go @@ -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) } diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 702b6c6d93..0ea4eff280 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -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) }) }) diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index 6ce147e765..fa118a204d 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -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) diff --git a/enterprise/coderd/coderdenttest/coderdenttest_test.go b/enterprise/coderd/coderdenttest/coderdenttest_test.go index ce8e0efc66..b6fb038d06 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest_test.go +++ b/enterprise/coderd/coderdenttest/coderdenttest_test.go @@ -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, diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index 63dd8ca4ce..47c15d7b0d 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -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 { diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index 15267c76fb..1dde6062e7 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -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) { diff --git a/enterprise/coderd/licenses_test.go b/enterprise/coderd/licenses_test.go index aa46491ab6..34317773d5 100644 --- a/enterprise/coderd/licenses_test.go +++ b/enterprise/coderd/licenses_test.go @@ -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"]) }) } diff --git a/enterprise/coderd/servicebanner.go b/enterprise/coderd/servicebanner.go deleted file mode 100644 index 3ab6e543ac..0000000000 --- a/enterprise/coderd/servicebanner.go +++ /dev/null @@ -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) -} diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index ff59edb492..58d3262364 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -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 = () => { } /> - + diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 0d6bd4917e..88a19561d4 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -723,15 +723,15 @@ export const getFile = async (fileId: string): Promise => { return response.data } -export const getServiceBanner = async (): Promise => { - const response = await axios.get(`/api/v2/service-banner`) +export const getAppearance = async (): Promise => { + const response = await axios.get(`/api/v2/appearance`) return response.data } -export const setServiceBanner = async ( - b: TypesGen.ServiceBanner, -): Promise => { - const response = await axios.put(`/api/v2/service-banner`, b) +export const updateAppearance = async ( + b: TypesGen.AppearanceConfig, +): Promise => { + const response = await axios.put(`/api/v2/appearance`, b) return response.data } diff --git a/site/src/api/types.ts b/site/src/api/types.ts index e93765b55e..cc8103a080 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -23,5 +23,5 @@ export enum FeatureNames { SCIM = "scim", TemplateRBAC = "template_rbac", HighAvailability = "high_availability", - ServiceBanners = "service_banners", + Appearance = "appearance", } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 72669b8f01..635ba8cd97 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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 diff --git a/site/src/components/DeploySettingsLayout/Fieldset.tsx b/site/src/components/DeploySettingsLayout/Fieldset.tsx new file mode 100644 index 0000000000..9bec8f2322 --- /dev/null +++ b/site/src/components/DeploySettingsLayout/Fieldset.tsx @@ -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 + isSubmitting?: boolean +}> = ({ title, children, validation, button, onSubmit, isSubmitting }) => { + const styles = useStyles() + + return ( +
+
+
{title}
+
{children}
+
+
+
{validation}
+ {button || ( + + )} +
+
+ ) +} + +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", + }, +})) diff --git a/site/src/components/DeploySettingsLayout/Sidebar.tsx b/site/src/components/DeploySettingsLayout/Sidebar.tsx index de9b27cccd..9e0b32f8c4 100644 --- a/site/src/components/DeploySettingsLayout/Sidebar.tsx +++ b/site/src/components/DeploySettingsLayout/Sidebar.tsx @@ -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 ( ) } diff --git a/site/src/components/Navbar/Navbar.tsx b/site/src/components/Navbar/Navbar.tsx index 31c48d0728..c1e6e7e104 100644 --- a/site/src/components/Navbar/Navbar.tsx +++ b/site/src/components/Navbar/Navbar.tsx @@ -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 ( void @@ -84,6 +85,7 @@ const NavItems: React.FC< } export const NavbarView: React.FC> = ({ user, + logo_url, buildInfo, onSignOut, canViewAuditLog, @@ -112,7 +114,11 @@ export const NavbarView: React.FC> = ({ >
- + {logo_url ? ( + Custom Logo + ) : ( + + )}
> = ({ - + {logo_url ? ( + Custom Logo + ) : ( + + )} ({ 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: { diff --git a/site/src/components/ServiceBanner/ServiceBanner.tsx b/site/src/components/ServiceBanner/ServiceBanner.tsx index b47f3d0c7d..23b113989e 100644 --- a/site/src/components/ServiceBanner/ServiceBanner.tsx +++ b/site/src/components/ServiceBanner/ServiceBanner.tsx @@ -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 = () => { ) } else { diff --git a/site/src/i18n/en/serviceBannerSettings.json b/site/src/i18n/en/appearanceSettings.json similarity index 100% rename from site/src/i18n/en/serviceBannerSettings.json rename to site/src/i18n/en/appearanceSettings.json diff --git a/site/src/i18n/en/index.ts b/site/src/i18n/en/index.ts index 9b38f40976..59674f9b7d 100644 --- a/site/src/i18n/en/index.ts +++ b/site/src/i18n/en/index.ts @@ -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, diff --git a/site/src/pages/DeploySettingsPage/AppearanceSettingsPage.tsx b/site/src/pages/DeploySettingsPage/AppearanceSettingsPage.tsx new file mode 100644 index 0000000000..eb3f534583 --- /dev/null +++ b/site/src/pages/DeploySettingsPage/AppearanceSettingsPage.tsx @@ -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, + 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({ + 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 ( + <> + + {pageTitle("Appearance Settings")} + + +
+ + + {isEntitled ? : } + + + +
+

+ Specify a custom URL for your logo to be displayed in the top left + corner of the dashboard. +

+ + (e.currentTarget.style.display = "none")} + onLoad={(e) => (e.currentTarget.style.display = "inline")} + /> + + ), + }} + /> +
+ +
{ + 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")} + + ) + } + validation={ + !isEntitled && ( +

+ Your license does not include Service Banners.{" "} + Contact sales to learn more. +

+ ) + } + > +

Configure a banner that displays a message to all users.

+ + {isEntitled && ( + + { + const newState = !serviceBannerForm.values.enabled + const newBanner = { + ...serviceBannerForm.values, + enabled: newState, + } + updateAppearance( + { + service_banner: newBanner, + }, + false, + ) + await serviceBannerForm.setFieldValue("enabled", newState) + }} + /> + } + label="Enabled" + /> + + + {t("messageHelperText")} + + + +

{"Background 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", + }, + }, + }} + /> +
+
+ )} +
+ + ) +} + +const useStyles = makeStyles((theme) => ({ + form: { + maxWidth: "500px", + }, + logoAdornment: { + width: theme.spacing(3), + height: theme.spacing(3), + + "& img": { + maxWidth: "100%", + }, + }, +})) + +export default AppearanceSettingsPage diff --git a/site/src/pages/DeploySettingsPage/ServiceBannerSettingsPage.tsx b/site/src/pages/DeploySettingsPage/ServiceBannerSettingsPage.tsx deleted file mode 100644 index 813a428bda..0000000000 --- a/site/src/pages/DeploySettingsPage/ServiceBannerSettingsPage.tsx +++ /dev/null @@ -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 = - useFormik({ - initialValues, - validationSchema, - onSubmit: (values) => setBanner(values, false), - }) - const getFieldHelpers = getFormHelpers(form) - - const [backgroundColor, setBackgroundColor] = useState( - form.values.backgroundColor, - ) - - const theme = useTheme() - const [t] = useTranslation("serviceBannerSettings") - - return ( - <> - - {pageTitle("Service Banner Settings")} - - -
- - {isEntitled ? : } - - - - {isEntitled ? ( -
- - { - const newState = !form.values.enabled - const newBanner = { - ...form.values, - enabled: newState, - } - setBanner(newBanner, false) - form.setFieldValue("enabled", newState) - }} - /> - } - label="Enabled" - /> - - { - form.setFieldValue("message", e.target.value) - setBanner( - { - ...form.values, - message: e.target.value, - }, - true, - ) - }} - /> - {t("messageHelperText")} - - - -

{"Background 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", - }, - }, - }} - /> -
- - - - {t("updateLabel")} - - -
-
- ) : ( - <> -

- Your license does not include Service Banners.{" "} - Contact sales to learn more. -

- - - )} - - ) -} - -const useStyles = makeStyles(() => ({ - form: { - maxWidth: "500px", - }, -})) - -export default ServiceBannerSettingsPage diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 026f39377b..fbaa5c034c 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -1097,3 +1097,10 @@ export const MockPermissions: Permissions = { viewAuditLog: true, viewDeploymentConfig: true, } + +export const MockAppearance: TypesGen.AppearanceConfig = { + logo_url: "", + service_banner: { + enabled: false, + }, +} diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 5fcd6154a7..92d3d8e8e6 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -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)) + }), ] diff --git a/site/src/xServices/StateContext.tsx b/site/src/xServices/StateContext.tsx index a085fb7958..5830057ec7 100644 --- a/site/src/xServices/StateContext.tsx +++ b/site/src/xServices/StateContext.tsx @@ -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 buildInfoXService: ActorRefFrom entitlementsXService: ActorRefFrom - serviceBannerXService: ActorRefFrom + appearanceXService: ActorRefFrom siteRolesXService: ActorRefFrom // Since the info here is used by multiple deployment settings page and we don't want to refetch them every time deploymentConfigXService: ActorRefFrom @@ -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), diff --git a/site/src/xServices/appearance/appearanceXService.ts b/site/src/xServices/appearance/appearanceXService.ts new file mode 100644 index 0000000000..610e3ff55e --- /dev/null +++ b/site/src/xServices/appearance/appearanceXService.ts @@ -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), + }, + }, +) diff --git a/site/src/xServices/serviceBanner/serviceBannerXService.ts b/site/src/xServices/serviceBanner/serviceBannerXService.ts deleted file mode 100644 index 9f8dd6d1ca..0000000000 --- a/site/src/xServices/serviceBanner/serviceBannerXService.ts +++ /dev/null @@ -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), - }, - }, -)