Add Service Banners (#5272)

This commit is contained in:
Ammar Bandukwala
2022-12-06 18:38:38 +00:00
committed by GitHub
parent df389d429c
commit 1cfe5de1c5
36 changed files with 959 additions and 4 deletions
@@ -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) {
+2
View File
@@ -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)
+21
View File
@@ -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
+7
View File
@@ -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';
+2
View File
@@ -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 {
+38
View File
@@ -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
}
+12
View File
@@ -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)
+1
View File
@@ -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

+1
View File
@@ -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

+7
View File
@@ -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",
+7
View File
@@ -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,
+7
View File
@@ -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 {
+4 -1
View File
@@ -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),
}),
+2
View File
@@ -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"])
})
}
+109
View File
@@ -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)
}
+66
View File
@@ -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)
}
+4
View File
@@ -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",
+17
View File
@@ -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={
+12
View File
@@ -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
}
+2
View File
@@ -22,4 +22,6 @@ export enum FeatureNames {
BrowserOnly = "browser_only",
SCIM = "scim",
TemplateRBAC = "template_rbac",
HighAvailability = "high_availability",
ServiceBanners = "service_banners",
}
+7
View File
@@ -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>
+2
View File
@@ -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"
}
+2
View File
@@ -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
+3
View File
@@ -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
View File
@@ -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"