mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
Add Service Banners (#5272)
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
You can access the Service Banner settings by navigating to
|
||||
`Deployment > Service Banners`.
|
||||
|
||||
## Up next
|
||||
|
||||
- [Enterprise](../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
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 910 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" ?><svg viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg"><path d="M0 0h48v48h-48z" fill="none"/><path d="M24 4c-11.05 0-20 8.95-20 20s8.95 20 20 20 20-8.95 20-20-8.95-20-20-20zm2 30h-4v-12h4v12zm0-16h-4v-4h4v4z"/></svg>
|
||||
|
After Width: | Height: | Size: 245 B |
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
|
||||
@@ -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"])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = () => {
|
||||
</AuthAndFrame>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="service-banner"
|
||||
element={
|
||||
<AuthAndFrame>
|
||||
<RequirePermission
|
||||
isFeatureVisible={Boolean(permissions?.viewDeploymentConfig)}
|
||||
>
|
||||
<DeploySettingsLayout>
|
||||
<ServiceBannerSettingsPage />
|
||||
</DeploySettingsLayout>
|
||||
</RequirePermission>
|
||||
</AuthAndFrame>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="network"
|
||||
element={
|
||||
|
||||
@@ -663,3 +663,15 @@ export const getFile = async (fileId: string): Promise<ArrayBuffer> => {
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getServiceBanner = async (): Promise<TypesGen.ServiceBanner> => {
|
||||
const response = await axios.get(`/api/v2/service-banner`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const setServiceBanner = async (
|
||||
b: TypesGen.ServiceBanner,
|
||||
): Promise<TypesGen.ServiceBanner> => {
|
||||
const response = await axios.put(`/api/v2/service-banner`, b)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -22,4 +22,6 @@ export enum FeatureNames {
|
||||
BrowserOnly = "browser_only",
|
||||
SCIM = "scim",
|
||||
TemplateRBAC = "template_rbac",
|
||||
HighAvailability = "high_availability",
|
||||
ServiceBanners = "service_banners",
|
||||
}
|
||||
|
||||
@@ -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<boolean>
|
||||
|
||||
@@ -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 = () => {
|
||||
<CssBaseline />
|
||||
<ErrorBoundary>
|
||||
<XServiceProvider>
|
||||
<ServiceBanner />
|
||||
<LicenseBanner />
|
||||
<AppRouter />
|
||||
<GlobalSnackbar />
|
||||
|
||||
@@ -12,6 +12,15 @@ export const EnabledBadge: React.FC = () => {
|
||||
)
|
||||
}
|
||||
|
||||
export const EntitledBadge: React.FC = () => {
|
||||
const styles = useStyles()
|
||||
return (
|
||||
<span className={combineClasses([styles.badge, styles.enabledBadge])}>
|
||||
Entitled
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export const DisabledBadge: React.FC = () => {
|
||||
const styles = useStyles()
|
||||
return (
|
||||
|
||||
@@ -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
|
||||
</SidebarNavItem>
|
||||
<SidebarNavItem
|
||||
href="../service-banner"
|
||||
icon={<SidebarNavItemIcon icon={Info} />}
|
||||
>
|
||||
Service Banner
|
||||
</SidebarNavItem>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<ServiceBannerView
|
||||
message={message}
|
||||
backgroundColor={background_color}
|
||||
preview={serviceBannerState.context.preview}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Story } from "@storybook/react"
|
||||
import { ServiceBannerView, ServiceBannerViewProps } from "./ServiceBannerView"
|
||||
|
||||
export default {
|
||||
title: "components/ServiceBannerView",
|
||||
component: ServiceBannerView,
|
||||
}
|
||||
|
||||
const Template: Story<ServiceBannerViewProps> = (args) => (
|
||||
<ServiceBannerView {...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,
|
||||
}
|
||||
@@ -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<ServiceBannerViewProps> = ({
|
||||
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 (
|
||||
<div
|
||||
className={`${styles.container}`}
|
||||
style={{ backgroundColor: backgroundColor }}
|
||||
>
|
||||
{preview && <Pill text="Preview" type="info" lightBorder />}
|
||||
<div
|
||||
className={styles.centerContent}
|
||||
style={{
|
||||
color: readableForegroundColor(backgroundColor),
|
||||
}}
|
||||
>
|
||||
<ReactMarkdown
|
||||
allowedElements={markdownElementsAllowed}
|
||||
linkTarget="_blank"
|
||||
unwrapDisallowed
|
||||
>
|
||||
{message}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"updateLabel": "Update",
|
||||
"messageHelperText": "Markdown bold, italics, and links are supported.",
|
||||
"showPreviewLabel": "Show Preview"
|
||||
}
|
||||
@@ -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<ServiceBannerFormValues> =
|
||||
useFormik<ServiceBannerFormValues>({
|
||||
initialValues,
|
||||
validationSchema,
|
||||
onSubmit: (values) => setBanner(values, false),
|
||||
})
|
||||
const getFieldHelpers = getFormHelpers<ServiceBannerFormValues>(form)
|
||||
|
||||
const [backgroundColor, setBackgroundColor] = useState(
|
||||
form.values.backgroundColor,
|
||||
)
|
||||
|
||||
const theme = useTheme()
|
||||
const [t] = useTranslation("serviceBannerSettings")
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{pageTitle("Service Banner Settings")}</title>
|
||||
</Helmet>
|
||||
|
||||
<Header
|
||||
title="Service Banner"
|
||||
description="Configure a banner that displays a message to all users"
|
||||
docsHref="https://coder.com/docs/coder-oss/latest/admin/service-banners"
|
||||
/>
|
||||
<Badges>
|
||||
{isEntitled ? <EntitledBadge /> : <DisabledBadge />}
|
||||
<EnterpriseBadge />
|
||||
</Badges>
|
||||
|
||||
{isEntitled ? (
|
||||
<form className={styles.form} onSubmit={form.handleSubmit}>
|
||||
<Stack>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
color="primary"
|
||||
checked={form.values.enabled}
|
||||
onChange={() => {
|
||||
const newState = !form.values.enabled
|
||||
const newBanner = {
|
||||
...form.values,
|
||||
enabled: newState,
|
||||
}
|
||||
setBanner(newBanner, false)
|
||||
form.setFieldValue("enabled", newState)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Enabled"
|
||||
/>
|
||||
<Stack spacing={0}>
|
||||
<TextField
|
||||
{...getFieldHelpers("message")}
|
||||
fullWidth
|
||||
label="Message"
|
||||
variant="outlined"
|
||||
multiline
|
||||
onChange={(e) => {
|
||||
form.setFieldValue("message", e.target.value)
|
||||
setBanner(
|
||||
{
|
||||
...form.values,
|
||||
message: e.target.value,
|
||||
},
|
||||
true,
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<FormHelperText>{t("messageHelperText")}</FormHelperText>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={0}>
|
||||
<h3>{"Background Color"}</h3>
|
||||
<BlockPicker
|
||||
color={backgroundColor}
|
||||
onChange={(color) => {
|
||||
setBackgroundColor(color.hex)
|
||||
form.setFieldValue("backgroundColor", color.hex)
|
||||
setBanner(
|
||||
{
|
||||
...form.values,
|
||||
backgroundColor: color.hex,
|
||||
},
|
||||
true,
|
||||
)
|
||||
}}
|
||||
triangle="hide"
|
||||
colors={["#004852", "#D65D0F", "#4CD473", "#D94A5D", "#5A00CF"]}
|
||||
styles={{
|
||||
default: {
|
||||
input: {
|
||||
color: "white",
|
||||
backgroundColor: theme.palette.background.default,
|
||||
},
|
||||
body: {
|
||||
backgroundColor: "black",
|
||||
color: "white",
|
||||
},
|
||||
card: {
|
||||
backgroundColor: "black",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row">
|
||||
<LoadingButton loading={false} type="submit" variant="contained">
|
||||
{t("updateLabel")}
|
||||
</LoadingButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
Your license does not include Service Banners.{" "}
|
||||
<a href="mailto:sales@coder.com">Contact sales</a> to learn more.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setBanner(
|
||||
{
|
||||
message:
|
||||
"👋 **This** is a service banner. The banner's color and text are editable.",
|
||||
backgroundColor: "#004852",
|
||||
enabled: true,
|
||||
},
|
||||
true,
|
||||
)
|
||||
}}
|
||||
>
|
||||
{t("showPreviewLabel")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
form: {
|
||||
maxWidth: "500px",
|
||||
},
|
||||
}))
|
||||
|
||||
export default ServiceBannerSettingsPage
|
||||
@@ -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<typeof authMachine>
|
||||
buildInfoXService: ActorRefFrom<typeof buildInfoMachine>
|
||||
entitlementsXService: ActorRefFrom<typeof entitlementsMachine>
|
||||
serviceBannerXService: ActorRefFrom<typeof serviceBannerMachine>
|
||||
siteRolesXService: ActorRefFrom<typeof siteRolesMachine>
|
||||
// Since the info here is used by multiple deployment settings page and we don't want to refetch them every time
|
||||
deploymentConfigXService: ActorRefFrom<typeof deploymentConfigMachine>
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
)
|
||||
+65
-3
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user