diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index f724f5448d03a..daea8c7afe609 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -122,6 +122,7 @@ type data struct { deploymentID string derpMeshKey string lastUpdateCheck []byte + serviceBanner []byte lastLicenseID int32 } @@ -3331,6 +3332,25 @@ func (q *fakeQuerier) GetLastUpdateCheck(_ context.Context) (string, error) { return string(q.lastUpdateCheck), nil } +func (q *fakeQuerier) InsertOrUpdateServiceBanner(_ context.Context, data string) error { + q.mutex.RLock() + defer q.mutex.RUnlock() + + q.serviceBanner = []byte(data) + return nil +} + +func (q *fakeQuerier) GetServiceBanner(_ context.Context) (string, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + if q.serviceBanner == nil { + return "", sql.ErrNoRows + } + + return string(q.serviceBanner), 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 81093f52eda84..3331d479f014e 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -76,6 +76,7 @@ type sqlcQuerier interface { GetQuotaAllowanceForUser(ctx context.Context, userID uuid.UUID) (int64, error) GetQuotaConsumedForUser(ctx context.Context, ownerID uuid.UUID) (int64, error) GetReplicasUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]Replica, error) + GetServiceBanner(ctx context.Context) (string, error) GetTemplateAverageBuildTime(ctx context.Context, arg GetTemplateAverageBuildTimeParams) (GetTemplateAverageBuildTimeRow, error) GetTemplateByID(ctx context.Context, id uuid.UUID) (Template, error) GetTemplateByOrganizationAndName(ctx context.Context, arg GetTemplateByOrganizationAndNameParams) (Template, error) @@ -144,6 +145,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 + InsertOrUpdateServiceBanner(ctx context.Context, value string) error InsertOrganization(ctx context.Context, arg InsertOrganizationParams) (Organization, error) InsertOrganizationMember(ctx context.Context, arg InsertOrganizationMemberParams) (OrganizationMember, error) InsertParameterSchema(ctx context.Context, arg InsertParameterSchemaParams) (ParameterSchema, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index eb6d3f732ac0c..f622b1907b440 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 getServiceBanner = `-- name: GetServiceBanner :one +SELECT value FROM site_configs WHERE key = 'service_banner' +` + +func (q *sqlQuerier) GetServiceBanner(ctx context.Context) (string, error) { + row := q.db.QueryRowContext(ctx, getServiceBanner) + var value string + err := row.Scan(&value) + return value, err +} + const insertDERPMeshKey = `-- name: InsertDERPMeshKey :exec INSERT INTO site_configs (key, value) VALUES ('derp_mesh_key', $1) ` @@ -3008,6 +3019,16 @@ func (q *sqlQuerier) InsertOrUpdateLastUpdateCheck(ctx context.Context, value st 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' +` + +func (q *sqlQuerier) InsertOrUpdateServiceBanner(ctx context.Context, value string) error { + _, err := q.db.ExecContext(ctx, insertOrUpdateServiceBanner, value) + return err +} + const getTemplateAverageBuildTime = `-- name: GetTemplateAverageBuildTime :one WITH build_times AS ( SELECT diff --git a/coderd/database/queries/siteconfig.sql b/coderd/database/queries/siteconfig.sql index 734b631a21f46..3962110f14f69 100644 --- a/coderd/database/queries/siteconfig.sql +++ b/coderd/database/queries/siteconfig.sql @@ -16,3 +16,10 @@ ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'last_update -- name: GetLastUpdateCheck :one SELECT value FROM site_configs WHERE key = 'last_update_check'; + +-- 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'; + +-- name: GetServiceBanner :one +SELECT value FROM site_configs WHERE key = 'service_banner'; diff --git a/codersdk/features.go b/codersdk/features.go index 3b707a7c42489..6469aaa35fac4 100644 --- a/codersdk/features.go +++ b/codersdk/features.go @@ -23,6 +23,7 @@ const ( FeatureHighAvailability = "high_availability" FeatureMultipleGitAuth = "multiple_git_auth" FeatureExternalProvisionerDaemons = "external_provisioner_daemons" + FeatureServiceBanners = "service_banners" ) var FeatureNames = []string{ @@ -34,6 +35,7 @@ var FeatureNames = []string{ FeatureHighAvailability, FeatureMultipleGitAuth, FeatureExternalProvisionerDaemons, + FeatureServiceBanners, } type Feature struct { diff --git a/codersdk/servicebanner.go b/codersdk/servicebanner.go new file mode 100644 index 0000000000000..8b56e3abf9de8 --- /dev/null +++ b/codersdk/servicebanner.go @@ -0,0 +1,38 @@ +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/docs/admin/service-banners.md b/docs/admin/service-banners.md new file mode 100644 index 0000000000000..95e49602b2311 --- /dev/null +++ b/docs/admin/service-banners.md @@ -0,0 +1,12 @@ +# Service Banners + +Service Banners let admins post important messages to all site users. Only Site Owners may set the service banner. + +![service banners](../images/admin/service-banners.png) + +You can access the Service Banner settings by navigating to +`Deployment > Service Banners`. + +## Up next + +- [Enterprise](../enterprise.md) diff --git a/docs/enterprise.md b/docs/enterprise.md index a616d11f6f8d3..95a46af2c3729 100644 --- a/docs/enterprise.md +++ b/docs/enterprise.md @@ -13,6 +13,7 @@ Contact sales@coder.com to obtain a license. - [High Availability](./admin/high-availability.md) - [Browser Only Connections](./networking.md#browser-only-connections) +- [Service Banners](./admin/service-banners.md) ### Other diff --git a/docs/images/admin/service-banners.png b/docs/images/admin/service-banners.png new file mode 100644 index 0000000000000..6f2b5fc7f1b57 Binary files /dev/null and b/docs/images/admin/service-banners.png differ diff --git a/docs/images/icons/info.svg b/docs/images/icons/info.svg new file mode 100644 index 0000000000000..d2aed1f22ddae --- /dev/null +++ b/docs/images/icons/info.svg @@ -0,0 +1 @@ + diff --git a/docs/manifest.json b/docs/manifest.json index c9df2463e0ddc..e2ef3483583a3 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -276,6 +276,13 @@ "icon_path": "./images/icons/speed.svg", "path": "./admin/prometheus.md" }, + { + "title": "Service Banners", + "description": "Learn how to configure Service Banners", + "icon_path": "./images/icons/info.svg", + "path": "./admin/service-banners.md", + "state": "enterprise" + }, { "title": "Telemetry", "description": "Learn what usage telemetry Coder collects", diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 5c3b88d936576..702b6c6d9353b 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -127,6 +127,13 @@ func New(ctx context.Context, options *Options) (*API, error) { r.Get("/", api.workspaceQuota) }) }) + r.Route("/service-banner", func(r chi.Router) { + r.Use( + apiKeyMiddleware, + ) + r.Get("/", api.serviceBanner) + r.Put("/", api.putServiceBanner) + }) }) if len(options.SCIMAPIKey) != 0 { diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index 0b36f938a8265..bf14d1acbc110 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -113,6 +113,7 @@ type LicenseOptions struct { HighAvailability bool MultipleGitAuth bool ExternalProvisionerDaemons bool + ServiceBanners bool } // AddLicense generates a new license with the options provided and inserts it. @@ -164,6 +165,11 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string { externalProvisionerDaemons = 1 } + serviceBanners := int64(0) + if options.ServiceBanners { + serviceBanners = 1 + } + c := &license.Claims{ RegisteredClaims: jwt.RegisteredClaims{ Issuer: "test@testing.test", @@ -186,6 +192,7 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string { TemplateRBAC: rbacEnabled, MultipleGitAuth: multipleGitAuth, ExternalProvisionerDaemons: externalProvisionerDaemons, + ServiceBanners: serviceBanners, }, } tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c) diff --git a/enterprise/coderd/coderdenttest/coderdenttest_test.go b/enterprise/coderd/coderdenttest/coderdenttest_test.go index e1a99291cd9f7..ce8e0efc664fa 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest_test.go +++ b/enterprise/coderd/coderdenttest/coderdenttest_test.go @@ -49,6 +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" assertRoute["GET:/api/v2/entitlements"] = coderdtest.RouteCheck{ NoAuthorize: true, diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index 1d3c5e4def11b..63dd8ca4ce792 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -123,6 +123,12 @@ func Entitlements( Enabled: true, } } + if claims.Features.ServiceBanners > 0 { + entitlements.Features[codersdk.FeatureServiceBanners] = codersdk.Feature{ + Entitlement: entitlement, + Enabled: true, + } + } if claims.AllFeatures { allFeatures = true } @@ -252,6 +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"` } type Claims struct { diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index c0833ed9c594b..15267c76fb124 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -27,6 +27,7 @@ func TestEntitlements(t *testing.T) { codersdk.FeatureTemplateRBAC: true, codersdk.FeatureMultipleGitAuth: true, codersdk.FeatureExternalProvisionerDaemons: true, + codersdk.FeatureServiceBanners: true, } t.Run("Defaults", func(t *testing.T) { @@ -70,6 +71,7 @@ func TestEntitlements(t *testing.T) { TemplateRBAC: true, MultipleGitAuth: true, ExternalProvisionerDaemons: true, + ServiceBanners: true, }), Exp: time.Now().Add(time.Hour), }) @@ -78,7 +80,7 @@ func TestEntitlements(t *testing.T) { require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) for _, featureName := range codersdk.FeatureNames { - require.Equal(t, codersdk.EntitlementEntitled, entitlements.Features[featureName].Entitlement) + require.Equal(t, codersdk.EntitlementEntitled, entitlements.Features[featureName].Entitlement, featureName) } }) t.Run("SingleLicenseGrace", func(t *testing.T) { @@ -93,6 +95,7 @@ func TestEntitlements(t *testing.T) { HighAvailability: true, TemplateRBAC: true, ExternalProvisionerDaemons: true, + ServiceBanners: true, GraceAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(time.Hour), }), diff --git a/enterprise/coderd/licenses_test.go b/enterprise/coderd/licenses_test.go index 4e105b7831e08..aa46491ab6383 100644 --- a/enterprise/coderd/licenses_test.go +++ b/enterprise/coderd/licenses_test.go @@ -109,6 +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"), }, licenses[0].Claims["features"]) assert.Equal(t, int32(2), licenses[1].ID) assert.Equal(t, "testing2", licenses[1].Claims["account_id"]) @@ -122,6 +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"), }, licenses[1].Claims["features"]) }) } diff --git a/enterprise/coderd/servicebanner.go b/enterprise/coderd/servicebanner.go new file mode 100644 index 0000000000000..3ab6e543ac444 --- /dev/null +++ b/enterprise/coderd/servicebanner.go @@ -0,0 +1,109 @@ +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/enterprise/coderd/servicebanner_test.go b/enterprise/coderd/servicebanner_test.go new file mode 100644 index 0000000000000..39ba8bd158f65 --- /dev/null +++ b/enterprise/coderd/servicebanner_test.go @@ -0,0 +1,66 @@ +package coderd_test + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/enterprise/coderd/coderdenttest" + "github.com/coder/coder/testutil" +) + +func TestServiceBanners(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + adminClient := coderdenttest.New(t, &coderdenttest.Options{}) + + adminUser := coderdtest.CreateFirstUser(t, adminClient) + + // Even without a license, the banner should return as disabled. + sb, err := adminClient.ServiceBanner(ctx) + require.NoError(t, err) + require.False(t, sb.Enabled) + + coderdenttest.AddLicense(t, adminClient, coderdenttest.LicenseOptions{ + ServiceBanners: true, + }) + + // Default state + sb, err = adminClient.ServiceBanner(ctx) + require.NoError(t, err) + require.False(t, sb.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) + require.Error(t, err) + var sdkError *codersdk.Error + require.True(t, errors.As(err, &sdkError)) + require.Equal(t, http.StatusForbidden, sdkError.StatusCode()) + + // But an admin can + wantBanner := sb + wantBanner.Enabled = true + wantBanner.Message = "Hey" + wantBanner.BackgroundColor = "#00FF00" + err = adminClient.SetServiceBanner(ctx, wantBanner) + require.NoError(t, err) + gotBanner, err := adminClient.ServiceBanner(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) + require.Error(t, err) +} diff --git a/site/package.json b/site/package.json index bc25960d5067f..e228a8903a9e0 100644 --- a/site/package.json +++ b/site/package.json @@ -33,6 +33,8 @@ "@material-ui/icons": "4.5.1", "@material-ui/lab": "4.0.0-alpha.42", "@testing-library/react-hooks": "8.0.1", + "@types/color-convert": "^2.0.0", + "@types/react-color": "^3.0.6", "@vitejs/plugin-react": "2.1.0", "@xstate/inspect": "0.6.5", "@xstate/react": "3.0.1", @@ -40,6 +42,7 @@ "can-ndjson-stream": "1.0.2", "chart.js": "3.9.1", "chartjs-adapter-date-fns": "2.0.0", + "color-convert": "^2.0.1", "cron-parser": "4.7.0", "cronstrue": "2.14.0", "date-fns": "2.29.3", @@ -54,6 +57,7 @@ "just-debounce-it": "3.1.1", "react": "18.2.0", "react-chartjs-2": "4.3.1", + "react-color": "^2.19.3", "react-dom": "18.2.0", "react-helmet-async": "1.3.0", "react-i18next": "12.0.0", diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 1b1cf04006a98..569b0241b5de1 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -76,6 +76,9 @@ const GeneralSettingsPage = lazy( const SecuritySettingsPage = lazy( () => import("./pages/DeploySettingsPage/SecuritySettingsPage"), ) +const ServiceBannerSettingsPage = lazy( + () => import("./pages/DeploySettingsPage/ServiceBannerSettingsPage"), +) const UserAuthSettingsPage = lazy( () => import("./pages/DeploySettingsPage/UserAuthSettingsPage"), ) @@ -303,6 +306,20 @@ export const AppRouter: FC = () => { } /> + + + + + + + + } + /> => { }) return response.data } + +export const getServiceBanner = async (): Promise => { + const response = await axios.get(`/api/v2/service-banner`) + return response.data +} + +export const setServiceBanner = async ( + b: TypesGen.ServiceBanner, +): Promise => { + const response = await axios.put(`/api/v2/service-banner`, b) + return response.data +} diff --git a/site/src/api/types.ts b/site/src/api/types.ts index 85115131052f2..e93765b55e19c 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -22,4 +22,6 @@ export enum FeatureNames { BrowserOnly = "browser_only", SCIM = "scim", TemplateRBAC = "template_rbac", + HighAvailability = "high_availability", + ServiceBanners = "service_banners", } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 242a860078787..f18086fa775da 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -604,6 +604,13 @@ export interface ServerSentEvent { readonly data: any } +// From codersdk/servicebanner.go +export interface ServiceBanner { + readonly enabled: boolean + readonly message?: string + readonly background_color?: string +} + // From codersdk/deploymentconfig.go export interface TLSConfig { readonly enable: DeploymentConfigField diff --git a/site/src/app.tsx b/site/src/app.tsx index f4441b4c98c28..11e3223ed78a2 100644 --- a/site/src/app.tsx +++ b/site/src/app.tsx @@ -1,6 +1,7 @@ import CssBaseline from "@material-ui/core/CssBaseline" import ThemeProvider from "@material-ui/styles/ThemeProvider" import { LicenseBanner } from "components/LicenseBanner/LicenseBanner" +import { ServiceBanner } from "components/ServiceBanner/ServiceBanner" import { FC } from "react" import { HelmetProvider } from "react-helmet-async" import { BrowserRouter as Router } from "react-router-dom" @@ -19,6 +20,7 @@ export const App: FC = () => { + diff --git a/site/src/components/DeploySettingsLayout/Badges.tsx b/site/src/components/DeploySettingsLayout/Badges.tsx index 7dd26009016ba..88a9bf1e7228d 100644 --- a/site/src/components/DeploySettingsLayout/Badges.tsx +++ b/site/src/components/DeploySettingsLayout/Badges.tsx @@ -12,6 +12,15 @@ export const EnabledBadge: React.FC = () => { ) } +export const EntitledBadge: React.FC = () => { + const styles = useStyles() + return ( + + Entitled + + ) +} + export const DisabledBadge: React.FC = () => { const styles = useStyles() return ( diff --git a/site/src/components/DeploySettingsLayout/Sidebar.tsx b/site/src/components/DeploySettingsLayout/Sidebar.tsx index 42d7ef45d3ac6..de9b27cccda91 100644 --- a/site/src/components/DeploySettingsLayout/Sidebar.tsx +++ b/site/src/components/DeploySettingsLayout/Sidebar.tsx @@ -3,6 +3,7 @@ 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" @@ -87,6 +88,12 @@ export const Sidebar: React.FC = () => { > Security + } + > + Service Banner + ) } diff --git a/site/src/components/ServiceBanner/ServiceBanner.tsx b/site/src/components/ServiceBanner/ServiceBanner.tsx new file mode 100644 index 0000000000000..b47f3d0c7dd32 --- /dev/null +++ b/site/src/components/ServiceBanner/ServiceBanner.tsx @@ -0,0 +1,34 @@ +import { useActor } from "@xstate/react" +import { useContext, useEffect } 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 { message, background_color, enabled } = + serviceBannerState.context.serviceBanner + + useEffect(() => { + serviceBannerSend("GET_BANNER") + }, [serviceBannerSend]) + + if (!enabled) { + return null + } + + if (message !== undefined && background_color !== undefined) { + return ( + + ) + } else { + return null + } +} diff --git a/site/src/components/ServiceBanner/ServiceBannerView.stories.tsx b/site/src/components/ServiceBanner/ServiceBannerView.stories.tsx new file mode 100644 index 0000000000000..37b6c4276b6f7 --- /dev/null +++ b/site/src/components/ServiceBanner/ServiceBannerView.stories.tsx @@ -0,0 +1,24 @@ +import { Story } from "@storybook/react" +import { ServiceBannerView, ServiceBannerViewProps } from "./ServiceBannerView" + +export default { + title: "components/ServiceBannerView", + component: ServiceBannerView, +} + +const Template: Story = (args) => ( + +) + +export const Production = Template.bind({}) +Production.args = { + message: "weeeee", + backgroundColor: "#FFFFFF", +} + +export const Preview = Template.bind({}) +Preview.args = { + message: "weeeee", + backgroundColor: "#000000", + preview: true, +} diff --git a/site/src/components/ServiceBanner/ServiceBannerView.tsx b/site/src/components/ServiceBanner/ServiceBannerView.tsx new file mode 100644 index 0000000000000..7561a29cdc22d --- /dev/null +++ b/site/src/components/ServiceBanner/ServiceBannerView.tsx @@ -0,0 +1,87 @@ +import { makeStyles } from "@material-ui/core/styles" +import { Pill } from "components/Pill/Pill" +import ReactMarkdown from "react-markdown" +import { colors } from "theme/colors" +import { hex } from "color-convert" + +export interface ServiceBannerViewProps { + message: string + backgroundColor: string + preview: boolean +} + +export const ServiceBannerView: React.FC = ({ + message, + backgroundColor, + preview, +}) => { + const styles = useStyles() + // We don't want anything funky like an image or a heading in the service + // banner. + const markdownElementsAllowed = [ + "text", + "a", + "pre", + "ul", + "strong", + "emphasis", + "italic", + "link", + "em", + ] + return ( +
+ {preview && } +
+ + {message} + +
+
+ ) +} + +const useStyles = makeStyles((theme) => ({ + container: { + padding: theme.spacing(1.5), + backgroundColor: theme.palette.warning.main, + display: "flex", + alignItems: "center", + "&.error": { + backgroundColor: colors.red[12], + }, + }, + flex: { + display: "column", + }, + centerContent: { + marginRight: "auto", + marginLeft: "auto", + fontWeight: 400, + "& a": { + color: "inherit", + }, + }, +})) + +const readableForegroundColor = (backgroundColor: string): string => { + const rgb = hex.rgb(backgroundColor) + + // Logic taken from here: + // https://github.com/casesandberg/react-color/blob/bc9a0e1dc5d11b06c511a8e02a95bd85c7129f4b/src/helpers/color.js#L56 + // to be consistent with the color-picker label. + const yiq = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000 + return yiq >= 128 ? "#000" : "#fff" +} diff --git a/site/src/i18n/en/index.ts b/site/src/i18n/en/index.ts index a8146f03aa316..4e2249981005a 100644 --- a/site/src/i18n/en/index.ts +++ b/site/src/i18n/en/index.ts @@ -11,6 +11,7 @@ import usersPage from "./usersPage.json" import templateVersionPage from "./templateVersionPage.json" import loginPage from "./loginPage.json" import workspaceChangeVersionPage from "./workspaceChangeVersionPage.json" +import serviceBannerSettings from "./serviceBannerSettings.json" export const en = { common, @@ -26,4 +27,5 @@ export const en = { templateVersionPage, loginPage, workspaceChangeVersionPage, + serviceBannerSettings, } diff --git a/site/src/i18n/en/serviceBannerSettings.json b/site/src/i18n/en/serviceBannerSettings.json new file mode 100644 index 0000000000000..9c12c28296ad3 --- /dev/null +++ b/site/src/i18n/en/serviceBannerSettings.json @@ -0,0 +1,5 @@ +{ + "updateLabel": "Update", + "messageHelperText": "Markdown bold, italics, and links are supported.", + "showPreviewLabel": "Show Preview" +} diff --git a/site/src/pages/DeploySettingsPage/ServiceBannerSettingsPage.tsx b/site/src/pages/DeploySettingsPage/ServiceBannerSettingsPage.tsx new file mode 100644 index 0000000000000..813a428bdaa65 --- /dev/null +++ b/site/src/pages/DeploySettingsPage/ServiceBannerSettingsPage.tsx @@ -0,0 +1,231 @@ +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/xServices/StateContext.tsx b/site/src/xServices/StateContext.tsx index 21a35b3c4dc33..a085fb795823c 100644 --- a/site/src/xServices/StateContext.tsx +++ b/site/src/xServices/StateContext.tsx @@ -7,11 +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" interface XServiceContextType { authXService: ActorRefFrom buildInfoXService: ActorRefFrom entitlementsXService: ActorRefFrom + serviceBannerXService: 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 @@ -35,6 +37,7 @@ export const XServiceProvider: FC<{ children: ReactNode }> = ({ children }) => { authXService: useInterpret(authMachine), buildInfoXService: useInterpret(buildInfoMachine), entitlementsXService: useInterpret(entitlementsMachine), + serviceBannerXService: useInterpret(serviceBannerMachine), siteRolesXService: useInterpret(siteRolesMachine), deploymentConfigXService: useInterpret(deploymentConfigMachine), updateCheckXService: useInterpret(updateCheckMachine), diff --git a/site/src/xServices/serviceBanner/serviceBannerXService.ts b/site/src/xServices/serviceBanner/serviceBannerXService.ts new file mode 100644 index 0000000000000..9f8dd6d1caa22 --- /dev/null +++ b/site/src/xServices/serviceBanner/serviceBannerXService.ts @@ -0,0 +1,134 @@ +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), + }, + }, +) diff --git a/site/yarn.lock b/site/yarn.lock index 9d8bd36864e64..b221cfd005fdd 100644 --- a/site/yarn.lock +++ b/site/yarn.lock @@ -1247,6 +1247,11 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== +"@icons/material@^0.2.4": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@icons/material/-/material-0.2.4.tgz#e90c9f71768b3736e76d7dd6783fc6c2afa88bc8" + integrity sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw== + "@istanbuljs/load-nyc-config@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" @@ -2892,6 +2897,18 @@ "@types/connect" "*" "@types/node" "*" +"@types/color-convert@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@types/color-convert/-/color-convert-2.0.0.tgz#8f5ee6b9e863dcbee5703f5a517ffb13d3ea4e22" + integrity sha512-m7GG7IKKGuJUXvkZ1qqG3ChccdIM/qBBo913z+Xft0nKCX4hAU/IxKwZBU4cpRZ7GS5kV4vOblUkILtSShCPXQ== + dependencies: + "@types/color-name" "*" + +"@types/color-name@*": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0" + integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ== + "@types/connect@*": version "3.4.35" resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1" @@ -3146,6 +3163,14 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== +"@types/react-color@^3.0.6": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/react-color/-/react-color-3.0.6.tgz#602fed023802b2424e7cd6ff3594ccd3d5055f9a" + integrity sha512-OzPIO5AyRmLA7PlOyISlgabpYUa3En74LP8mTMa0veCA719SvYQov4WLMsHvCgXP+L+KI9yGhYnqZafVGG0P4w== + dependencies: + "@types/react" "*" + "@types/reactcss" "*" + "@types/react-dom@18.0.6": version "18.0.6" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.0.6.tgz#36652900024842b74607a17786b6662dd1e103a1" @@ -3199,6 +3224,13 @@ "@types/scheduler" "*" csstype "^3.0.2" +"@types/reactcss@*": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@types/reactcss/-/reactcss-1.2.6.tgz#133c1e7e896f2726370d1d5a26bf06a30a038bcc" + integrity sha512-qaIzpCuXNWomGR1Xq8SCFTtF4v8V27Y6f+b9+bzHiv087MylI/nTCqqdChNeWS7tslgROmYB7yeiruWX7WnqNg== + dependencies: + "@types/react" "*" + "@types/scheduler@*": version "0.16.2" resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" @@ -9459,7 +9491,7 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" -lodash-es@^4.17.21: +lodash-es@^4.17.15, lodash-es@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== @@ -9484,7 +9516,7 @@ lodash.uniq@4.5.0: resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ== -lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.7.0: +lodash@^4.0.1, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.7.0: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -9622,6 +9654,11 @@ markdown-table@^3.0.0: resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-3.0.2.tgz#9b59eb2c1b22fe71954a65ff512887065a7bb57c" integrity sha512-y8j3a5/DkJCmS5x4dMCQL+OR0+2EAq3DOtio1COSHsmW2BGXnNCK3v12hJt1LrUz5iZH5g0LmuYOjDdI+czghA== +material-colors@^1.2.1: + version "1.2.6" + resolved "https://registry.yarnpkg.com/material-colors/-/material-colors-1.2.6.tgz#6d1958871126992ceecc72f4bcc4d8f010865f46" + integrity sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg== + md5.js@^1.3.4: version "1.3.5" resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" @@ -11372,7 +11409,7 @@ prompts@^2.0.1, prompts@^2.4.0: kleur "^3.0.3" sisteransi "^1.0.5" -prop-types@^15.0.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: +prop-types@^15.0.0, prop-types@^15.5.10, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -11543,6 +11580,19 @@ react-chartjs-2@4.3.1: resolved "https://registry.yarnpkg.com/react-chartjs-2/-/react-chartjs-2-4.3.1.tgz#9941e7397fb963f28bb557addb401e9ff96c6681" integrity sha512-5i3mjP6tU7QSn0jvb8I4hudTzHJqS8l00ORJnVwI2sYu0ihpj83Lv2YzfxunfxTZkscKvZu2F2w9LkwNBhj6xA== +react-color@^2.19.3: + version "2.19.3" + resolved "https://registry.yarnpkg.com/react-color/-/react-color-2.19.3.tgz#ec6c6b4568312a3c6a18420ab0472e146aa5683d" + integrity sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA== + dependencies: + "@icons/material" "^0.2.4" + lodash "^4.17.15" + lodash-es "^4.17.15" + material-colors "^1.2.1" + prop-types "^15.5.10" + reactcss "^1.2.0" + tinycolor2 "^1.4.1" + react-docgen-typescript@^2.1.1: version "2.2.2" resolved "https://registry.yarnpkg.com/react-docgen-typescript/-/react-docgen-typescript-2.2.2.tgz#4611055e569edc071204aadb20e1c93e1ab1659c" @@ -11715,6 +11765,13 @@ react@18.2.0: dependencies: loose-envify "^1.1.0" +reactcss@^1.2.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/reactcss/-/reactcss-1.2.3.tgz#c00013875e557b1cf0dfd9a368a1c3dab3b548dd" + integrity sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A== + dependencies: + lodash "^4.0.1" + read-pkg-up@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02" @@ -13115,6 +13172,11 @@ tiny-warning@^1.0.2: resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== +tinycolor2@^1.4.1: + version "1.4.2" + resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.4.2.tgz#3f6a4d1071ad07676d7fa472e1fac40a719d8803" + integrity sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA== + tmp@^0.0.33: version "0.0.33" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"