This commit is contained in:
Kayla Washburn-Love 2024-05-01 21:47:29 +00:00 committed by GitHub
commit a03152793a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 631 additions and 100 deletions

View File

@ -63,7 +63,7 @@ type Config struct {
// file will be displayed to the user upon login.
MOTDFile func() string
// ServiceBanner returns the configuration for the Coder service banner.
ServiceBanner func() *codersdk.ServiceBannerConfig
ServiceBanner func() *codersdk.BannerConfig
// UpdateEnv updates the environment variables for the command to be
// executed. It can be used to add, modify or replace environment variables.
UpdateEnv func(current []string) (updated []string, err error)
@ -124,7 +124,7 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom
config.MOTDFile = func() string { return "" }
}
if config.ServiceBanner == nil {
config.ServiceBanner = func() *codersdk.ServiceBannerConfig { return &codersdk.ServiceBannerConfig{} }
config.ServiceBanner = func() *codersdk.BannerConfig { return &codersdk.BannerConfig{} }
}
if config.WorkingDirectory == nil {
config.WorkingDirectory = func() string {

54
coderd/apidoc/docs.go generated
View File

@ -8272,8 +8272,19 @@ const docTemplate = `{
"logo_url": {
"type": "string"
},
"notification_banners": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.BannerConfig"
}
},
"service_banner": {
"$ref": "#/definitions/codersdk.ServiceBannerConfig"
"description": "ServiceBanner is for a single banner, and has been replaced by NotificationBanners. Deprecated.",
"allOf": [
{
"$ref": "#/definitions/codersdk.BannerConfig"
}
]
},
"support_links": {
"type": "array",
@ -8530,6 +8541,20 @@ const docTemplate = `{
"AutomaticUpdatesNever"
]
},
"codersdk.BannerConfig": {
"type": "object",
"properties": {
"background_color": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"message": {
"type": "string"
}
}
},
"codersdk.BuildInfoResponse": {
"type": "object",
"properties": {
@ -11058,20 +11083,6 @@ const docTemplate = `{
}
}
},
"codersdk.ServiceBannerConfig": {
"type": "object",
"properties": {
"background_color": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"message": {
"type": "string"
}
}
},
"codersdk.SessionCountDeploymentStats": {
"type": "object",
"properties": {
@ -11904,8 +11915,19 @@ const docTemplate = `{
"logo_url": {
"type": "string"
},
"notification_banners": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.BannerConfig"
}
},
"service_banner": {
"$ref": "#/definitions/codersdk.ServiceBannerConfig"
"description": "ServiceBanner is for a single banner, and has been replaced by NotificationBanners. Deprecated.",
"allOf": [
{
"$ref": "#/definitions/codersdk.BannerConfig"
}
]
}
}
},

View File

@ -7341,8 +7341,19 @@
"logo_url": {
"type": "string"
},
"notification_banners": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.BannerConfig"
}
},
"service_banner": {
"$ref": "#/definitions/codersdk.ServiceBannerConfig"
"description": "ServiceBanner is for a single banner, and has been replaced by NotificationBanners. Deprecated.",
"allOf": [
{
"$ref": "#/definitions/codersdk.BannerConfig"
}
]
},
"support_links": {
"type": "array",
@ -7588,6 +7599,20 @@
"enum": ["always", "never"],
"x-enum-varnames": ["AutomaticUpdatesAlways", "AutomaticUpdatesNever"]
},
"codersdk.BannerConfig": {
"type": "object",
"properties": {
"background_color": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"message": {
"type": "string"
}
}
},
"codersdk.BuildInfoResponse": {
"type": "object",
"properties": {
@ -9960,20 +9985,6 @@
}
}
},
"codersdk.ServiceBannerConfig": {
"type": "object",
"properties": {
"background_color": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"message": {
"type": "string"
}
}
},
"codersdk.SessionCountDeploymentStats": {
"type": "object",
"properties": {
@ -10763,8 +10774,19 @@
"logo_url": {
"type": "string"
},
"notification_banners": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.BannerConfig"
}
},
"service_banner": {
"$ref": "#/definitions/codersdk.ServiceBannerConfig"
"description": "ServiceBanner is for a single banner, and has been replaced by NotificationBanners. Deprecated.",
"allOf": [
{
"$ref": "#/definitions/codersdk.BannerConfig"
}
]
}
}
},

View File

@ -1220,6 +1220,11 @@ func (q *querier) GetLogoURL(ctx context.Context) (string, error) {
return q.db.GetLogoURL(ctx)
}
func (q *querier) GetNotificationBanners(ctx context.Context) (string, error) {
// No authz checks
return q.db.GetNotificationBanners(ctx)
}
func (q *querier) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) {
if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceOAuth2ProviderApp); err != nil {
return database.OAuth2ProviderApp{}, err
@ -3364,6 +3369,13 @@ func (q *querier) UpsertLogoURL(ctx context.Context, value string) error {
return q.db.UpsertLogoURL(ctx, value)
}
func (q *querier) UpsertNotificationBanners(ctx context.Context, value string) error {
if err := q.authorizeContext(ctx, rbac.ActionCreate, rbac.ResourceDeploymentValues); err != nil {
return err
}
return q.db.UpsertNotificationBanners(ctx, value)
}
func (q *querier) UpsertOAuthSigningKey(ctx context.Context, value string) error {
if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceSystem); err != nil {
return err

View File

@ -528,6 +528,9 @@ func (s *MethodTestSuite) TestLicense() {
s.Run("UpsertServiceBanner", s.Subtest(func(db database.Store, check *expects) {
check.Args("value").Asserts(rbac.ResourceDeploymentValues, rbac.ActionCreate)
}))
s.Run("UpsertNotificationBanners", s.Subtest(func(db database.Store, check *expects) {
check.Args("value").Asserts(rbac.ResourceDeploymentValues, rbac.ActionCreate)
}))
s.Run("GetLicenseByID", s.Subtest(func(db database.Store, check *expects) {
l, err := db.InsertLicense(context.Background(), database.InsertLicenseParams{
UUID: uuid.New(),
@ -561,6 +564,11 @@ func (s *MethodTestSuite) TestLicense() {
require.NoError(s.T(), err)
check.Args().Asserts().Returns("value")
}))
s.Run("GetNotificationBanners", s.Subtest(func(db database.Store, check *expects) {
err := db.UpsertNotificationBanners(context.Background(), "value")
require.NoError(s.T(), err)
check.Args().Asserts().Returns("value")
}))
}
func (s *MethodTestSuite) TestOrganization() {

View File

@ -186,6 +186,7 @@ type data struct {
derpMeshKey string
lastUpdateCheck []byte
serviceBanner []byte
notificationBanners []byte
healthSettings []byte
applicationName string
logoURL string
@ -2488,6 +2489,17 @@ func (q *FakeQuerier) GetLogoURL(_ context.Context) (string, error) {
return q.logoURL, nil
}
func (q *FakeQuerier) GetNotificationBanners(_ context.Context) (string, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
if q.notificationBanners == nil {
return "", sql.ErrNoRows
}
return string(q.notificationBanners), nil
}
func (q *FakeQuerier) GetOAuth2ProviderAppByID(_ context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
@ -8251,6 +8263,14 @@ func (q *FakeQuerier) UpsertLogoURL(_ context.Context, data string) error {
return nil
}
func (q *FakeQuerier) UpsertNotificationBanners(_ context.Context, data string) error {
q.mutex.RLock()
defer q.mutex.RUnlock()
q.notificationBanners = []byte(data)
return nil
}
func (q *FakeQuerier) UpsertOAuthSigningKey(_ context.Context, value string) error {
q.mutex.Lock()
defer q.mutex.Unlock()

View File

@ -646,6 +646,13 @@ func (m metricsStore) GetLogoURL(ctx context.Context) (string, error) {
return url, err
}
func (m metricsStore) GetNotificationBanners(ctx context.Context) (string, error) {
start := time.Now()
r0, r1 := m.s.GetNotificationBanners(ctx)
m.queryLatencies.WithLabelValues("GetNotificationBanners").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m metricsStore) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) {
start := time.Now()
r0, r1 := m.s.GetOAuth2ProviderAppByID(ctx, id)
@ -2186,6 +2193,13 @@ func (m metricsStore) UpsertLogoURL(ctx context.Context, value string) error {
return r0
}
func (m metricsStore) UpsertNotificationBanners(ctx context.Context, value string) error {
start := time.Now()
r0 := m.s.UpsertNotificationBanners(ctx, value)
m.queryLatencies.WithLabelValues("UpsertNotificationBanners").Observe(time.Since(start).Seconds())
return r0
}
func (m metricsStore) UpsertOAuthSigningKey(ctx context.Context, value string) error {
start := time.Now()
r0 := m.s.UpsertOAuthSigningKey(ctx, value)

View File

@ -1275,6 +1275,21 @@ func (mr *MockStoreMockRecorder) GetLogoURL(arg0 any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLogoURL", reflect.TypeOf((*MockStore)(nil).GetLogoURL), arg0)
}
// GetNotificationBanners mocks base method.
func (m *MockStore) GetNotificationBanners(arg0 context.Context) (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetNotificationBanners", arg0)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetNotificationBanners indicates an expected call of GetNotificationBanners.
func (mr *MockStoreMockRecorder) GetNotificationBanners(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotificationBanners", reflect.TypeOf((*MockStore)(nil).GetNotificationBanners), arg0)
}
// GetOAuth2ProviderAppByID mocks base method.
func (m *MockStore) GetOAuth2ProviderAppByID(arg0 context.Context, arg1 uuid.UUID) (database.OAuth2ProviderApp, error) {
m.ctrl.T.Helper()
@ -4577,6 +4592,20 @@ func (mr *MockStoreMockRecorder) UpsertLogoURL(arg0, arg1 any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertLogoURL", reflect.TypeOf((*MockStore)(nil).UpsertLogoURL), arg0, arg1)
}
// UpsertNotificationBanners mocks base method.
func (m *MockStore) UpsertNotificationBanners(arg0 context.Context, arg1 string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpsertNotificationBanners", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// UpsertNotificationBanners indicates an expected call of UpsertNotificationBanners.
func (mr *MockStoreMockRecorder) UpsertNotificationBanners(arg0, arg1 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertNotificationBanners", reflect.TypeOf((*MockStore)(nil).UpsertNotificationBanners), arg0, arg1)
}
// UpsertOAuthSigningKey mocks base method.
func (m *MockStore) UpsertOAuthSigningKey(arg0 context.Context, arg1 string) error {
m.ctrl.T.Helper()

View File

@ -135,6 +135,7 @@ type sqlcQuerier interface {
GetLicenseByID(ctx context.Context, id int32) (License, error)
GetLicenses(ctx context.Context) ([]License, error)
GetLogoURL(ctx context.Context) (string, error)
GetNotificationBanners(ctx context.Context) (string, error)
GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error)
GetOAuth2ProviderAppCodeByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderAppCode, error)
GetOAuth2ProviderAppCodeByPrefix(ctx context.Context, secretPrefix []byte) (OAuth2ProviderAppCode, error)
@ -421,6 +422,7 @@ type sqlcQuerier interface {
UpsertJFrogXrayScanByWorkspaceAndAgentID(ctx context.Context, arg UpsertJFrogXrayScanByWorkspaceAndAgentIDParams) error
UpsertLastUpdateCheck(ctx context.Context, value string) error
UpsertLogoURL(ctx context.Context, value string) error
UpsertNotificationBanners(ctx context.Context, value string) error
UpsertOAuthSigningKey(ctx context.Context, value string) error
UpsertProvisionerDaemon(ctx context.Context, arg UpsertProvisionerDaemonParams) (ProvisionerDaemon, error)
UpsertServiceBanner(ctx context.Context, value string) error

View File

@ -5615,6 +5615,17 @@ func (q *sqlQuerier) GetLogoURL(ctx context.Context) (string, error) {
return value, err
}
const getNotificationBanners = `-- name: GetNotificationBanners :one
SELECT value FROM site_configs WHERE key = 'notification_banners'
`
func (q *sqlQuerier) GetNotificationBanners(ctx context.Context) (string, error) {
row := q.db.QueryRowContext(ctx, getNotificationBanners)
var value string
err := row.Scan(&value)
return value, err
}
const getOAuthSigningKey = `-- name: GetOAuthSigningKey :one
SELECT value FROM site_configs WHERE key = 'oauth_signing_key'
`
@ -5728,6 +5739,16 @@ func (q *sqlQuerier) UpsertLogoURL(ctx context.Context, value string) error {
return err
}
const upsertNotificationBanners = `-- name: UpsertNotificationBanners :exec
INSERT INTO site_configs (key, value) VALUES ('notification_banners', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'notification_banners'
`
func (q *sqlQuerier) UpsertNotificationBanners(ctx context.Context, value string) error {
_, err := q.db.ExecContext(ctx, upsertNotificationBanners, value)
return err
}
const upsertOAuthSigningKey = `-- name: UpsertOAuthSigningKey :exec
INSERT INTO site_configs (key, value) VALUES ('oauth_signing_key', $1)
ON CONFLICT (key) DO UPDATE set value = $1 WHERE site_configs.key = 'oauth_signing_key'

View File

@ -43,6 +43,13 @@ ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'service_ban
-- name: GetServiceBanner :one
SELECT value FROM site_configs WHERE key = 'service_banner';
-- name: UpsertNotificationBanners :exec
INSERT INTO site_configs (key, value) VALUES ('notification_banners', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'notification_banners';
-- name: GetNotificationBanners :one
SELECT value FROM site_configs WHERE key = 'notification_banners';
-- name: UpsertLogoURL :exec
INSERT INTO site_configs (key, value) VALUES ('logo_url', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'logo_url';

View File

@ -277,15 +277,15 @@ func ProtoFromApp(a codersdk.WorkspaceApp) (*proto.WorkspaceApp, error) {
}, nil
}
func ServiceBannerFromProto(sbp *proto.ServiceBanner) codersdk.ServiceBannerConfig {
return codersdk.ServiceBannerConfig{
func ServiceBannerFromProto(sbp *proto.ServiceBanner) codersdk.BannerConfig {
return codersdk.BannerConfig{
Enabled: sbp.GetEnabled(),
Message: sbp.GetMessage(),
BackgroundColor: sbp.GetBackgroundColor(),
}
}
func ProtoFromServiceBanner(sb codersdk.ServiceBannerConfig) *proto.ServiceBanner {
func ProtoFromServiceBanner(sb codersdk.BannerConfig) *proto.ServiceBanner {
return &proto.ServiceBanner{
Enabled: sb.Enabled,
Message: sb.Message,

View File

@ -2084,19 +2084,26 @@ func (c *Client) DeploymentStats(ctx context.Context) (DeploymentStats, error) {
}
type AppearanceConfig struct {
ApplicationName string `json:"application_name"`
LogoURL string `json:"logo_url"`
ServiceBanner ServiceBannerConfig `json:"service_banner"`
SupportLinks []LinkConfig `json:"support_links,omitempty"`
ApplicationName string `json:"application_name"`
LogoURL string `json:"logo_url"`
// ServiceBanner is for a single banner, and has been replaced by NotificationBanners. Deprecated.
ServiceBanner BannerConfig `json:"service_banner"`
NotificationBanners []BannerConfig `json:"notification_banners"`
SupportLinks []LinkConfig `json:"support_links,omitempty"`
}
type UpdateAppearanceConfig struct {
ApplicationName string `json:"application_name"`
LogoURL string `json:"logo_url"`
ServiceBanner ServiceBannerConfig `json:"service_banner"`
ApplicationName string `json:"application_name"`
LogoURL string `json:"logo_url"`
// ServiceBanner is for a single banner, and has been replaced by NotificationBanners. Deprecated.
ServiceBanner BannerConfig `json:"service_banner"`
NotificationBanners []BannerConfig `json:"notification_banners"`
}
type ServiceBannerConfig struct {
// ServiceBannerConfig has been renamed to BannerConfig.
type ServiceBannerConfig = BannerConfig
type BannerConfig struct {
Enabled bool `json:"enabled"`
Message string `json:"message,omitempty"`
BackgroundColor string `json:"background_color,omitempty"`

21
docs/api/enterprise.md generated
View File

@ -21,6 +21,13 @@ curl -X GET http://coder-server:8080/api/v2/appearance \
{
"application_name": "string",
"logo_url": "string",
"notification_banners": [
{
"background_color": "string",
"enabled": true,
"message": "string"
}
],
"service_banner": {
"background_color": "string",
"enabled": true,
@ -64,6 +71,13 @@ curl -X PUT http://coder-server:8080/api/v2/appearance \
{
"application_name": "string",
"logo_url": "string",
"notification_banners": [
{
"background_color": "string",
"enabled": true,
"message": "string"
}
],
"service_banner": {
"background_color": "string",
"enabled": true,
@ -86,6 +100,13 @@ curl -X PUT http://coder-server:8080/api/v2/appearance \
{
"application_name": "string",
"logo_url": "string",
"notification_banners": [
{
"background_color": "string",
"enabled": true,
"message": "string"
}
],
"service_banner": {
"background_color": "string",
"enabled": true,

74
docs/api/schemas.md generated
View File

@ -751,6 +751,13 @@
{
"application_name": "string",
"logo_url": "string",
"notification_banners": [
{
"background_color": "string",
"enabled": true,
"message": "string"
}
],
"service_banner": {
"background_color": "string",
"enabled": true,
@ -768,12 +775,13 @@
### Properties
| Name | Type | Required | Restrictions | Description |
| ------------------ | ------------------------------------------------------------ | -------- | ------------ | ----------- |
| `application_name` | string | false | | |
| `logo_url` | string | false | | |
| `service_banner` | [codersdk.ServiceBannerConfig](#codersdkservicebannerconfig) | false | | |
| `support_links` | array of [codersdk.LinkConfig](#codersdklinkconfig) | false | | |
| Name | Type | Required | Restrictions | Description |
| ---------------------- | ------------------------------------------------------- | -------- | ------------ | ------------------------------------------------------------------------------------------------ |
| `application_name` | string | false | | |
| `logo_url` | string | false | | |
| `notification_banners` | array of [codersdk.BannerConfig](#codersdkbannerconfig) | false | | |
| `service_banner` | [codersdk.BannerConfig](#codersdkbannerconfig) | false | | Service banner is for a single banner, and has been replaced by NotificationBanners. Deprecated. |
| `support_links` | array of [codersdk.LinkConfig](#codersdklinkconfig) | false | | |
## codersdk.ArchiveTemplateVersionsRequest
@ -1172,6 +1180,24 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `always` |
| `never` |
## codersdk.BannerConfig
```json
{
"background_color": "string",
"enabled": true,
"message": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| ------------------ | ------- | -------- | ------------ | ----------- |
| `background_color` | string | false | | |
| `enabled` | boolean | false | | |
| `message` | string | false | | |
## codersdk.BuildInfoResponse
```json
@ -4265,24 +4291,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
| `ssh_config_options` | object | false | | |
| » `[any property]` | string | false | | |
## codersdk.ServiceBannerConfig
```json
{
"background_color": "string",
"enabled": true,
"message": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| ------------------ | ------- | -------- | ------------ | ----------- |
| `background_color` | string | false | | |
| `enabled` | boolean | false | | |
| `message` | string | false | | |
## codersdk.SessionCountDeploymentStats
```json
@ -5175,6 +5183,13 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
{
"application_name": "string",
"logo_url": "string",
"notification_banners": [
{
"background_color": "string",
"enabled": true,
"message": "string"
}
],
"service_banner": {
"background_color": "string",
"enabled": true,
@ -5185,11 +5200,12 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
### Properties
| Name | Type | Required | Restrictions | Description |
| ------------------ | ------------------------------------------------------------ | -------- | ------------ | ----------- |
| `application_name` | string | false | | |
| `logo_url` | string | false | | |
| `service_banner` | [codersdk.ServiceBannerConfig](#codersdkservicebannerconfig) | false | | |
| Name | Type | Required | Restrictions | Description |
| ---------------------- | ------------------------------------------------------- | -------- | ------------ | ------------------------------------------------------------------------------------------------ |
| `application_name` | string | false | | |
| `logo_url` | string | false | | |
| `notification_banners` | array of [codersdk.BannerConfig](#codersdkbannerconfig) | false | | |
| `service_banner` | [codersdk.BannerConfig](#codersdkbannerconfig) | false | | Service banner is for a single banner, and has been replaced by NotificationBanners. Deprecated. |
## codersdk.UpdateCheckResponse

View File

@ -6,6 +6,7 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"golang.org/x/sync/errgroup"
@ -53,9 +54,12 @@ func newAppearanceFetcher(store database.Store, links []codersdk.LinkConfig) agp
func (f *appearanceFetcher) Fetch(ctx context.Context) (codersdk.AppearanceConfig, error) {
var eg errgroup.Group
var applicationName string
var logoURL string
var serviceBannerJSON string
var (
applicationName string
logoURL string
serviceBannerJSON string
notificationBannersJSON string
)
eg.Go(func() (err error) {
applicationName, err = f.database.GetApplicationName(ctx)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
@ -77,27 +81,42 @@ func (f *appearanceFetcher) Fetch(ctx context.Context) (codersdk.AppearanceConfi
}
return nil
})
eg.Go(func() (err error) {
notificationBannersJSON, err = f.database.GetNotificationBanners(ctx)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return xerrors.Errorf("get notification banners: %w", err)
}
return nil
})
err := eg.Wait()
if err != nil {
return codersdk.AppearanceConfig{}, err
}
cfg := codersdk.AppearanceConfig{
ApplicationName: applicationName,
LogoURL: logoURL,
ApplicationName: applicationName,
LogoURL: logoURL,
NotificationBanners: []codersdk.BannerConfig{},
SupportLinks: agpl.DefaultSupportLinks,
}
if serviceBannerJSON != "" {
err = json.Unmarshal([]byte(serviceBannerJSON), &cfg.ServiceBanner)
if err != nil {
return codersdk.AppearanceConfig{}, xerrors.Errorf(
"unmarshal json: %w, raw: %s", err, serviceBannerJSON,
"unmarshal service banner json: %w, raw: %s", err, serviceBannerJSON,
)
}
}
if len(f.supportLinks) == 0 {
cfg.SupportLinks = agpl.DefaultSupportLinks
} else {
if notificationBannersJSON != "" {
err = json.Unmarshal([]byte(notificationBannersJSON), &cfg.NotificationBanners)
if err != nil {
return codersdk.AppearanceConfig{}, xerrors.Errorf(
"unmarshal notification banners json: %w, raw: %s", err, notificationBannersJSON,
)
}
}
if len(f.supportLinks) > 0 {
cfg.SupportLinks = f.supportLinks
}
@ -142,7 +161,7 @@ func (api *API) putAppearance(rw http.ResponseWriter, r *http.Request) {
if appearance.ServiceBanner.Enabled {
if err := validateHexColor(appearance.ServiceBanner.BackgroundColor); err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid color format",
Message: fmt.Sprintf("Invalid color format: %q", appearance.ServiceBanner.BackgroundColor),
Detail: err.Error(),
})
return
@ -167,6 +186,34 @@ func (api *API) putAppearance(rw http.ResponseWriter, r *http.Request) {
return
}
for _, banner := range appearance.NotificationBanners {
if err := validateHexColor(banner.BackgroundColor); err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Invalid color format: %q", banner.BackgroundColor),
Detail: err.Error(),
})
return
}
}
notificationBannersJSON, err := json.Marshal(appearance.NotificationBanners)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Unable to marshal notification banners",
Detail: err.Error(),
})
return
}
err = api.Database.UpsertNotificationBanners(ctx, string(notificationBannersJSON))
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Unable to set notification banners",
Detail: err.Error(),
})
return
}
err = api.Database.UpsertApplicationName(ctx, appearance.ApplicationName)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{

View File

@ -141,7 +141,7 @@ func TestServiceBanners(t *testing.T) {
},
})
cfg := codersdk.UpdateAppearanceConfig{
ServiceBanner: codersdk.ServiceBannerConfig{
ServiceBanner: codersdk.BannerConfig{
Enabled: true,
Message: "Hey",
BackgroundColor: "#00FF00",
@ -165,17 +165,17 @@ func TestServiceBanners(t *testing.T) {
agplAgentClient := agentsdk.New(agplClient.URL)
agplAgentClient.SetSessionToken(r.AgentToken)
banner = requireGetServiceBanner(ctx, t, agplAgentClient)
require.Equal(t, codersdk.ServiceBannerConfig{}, banner)
require.Equal(t, codersdk.BannerConfig{}, banner)
// No license means no banner.
err = client.DeleteLicense(ctx, lic.ID)
require.NoError(t, err)
banner = requireGetServiceBanner(ctx, t, agentClient)
require.Equal(t, codersdk.ServiceBannerConfig{}, banner)
require.Equal(t, codersdk.BannerConfig{}, banner)
})
}
func requireGetServiceBanner(ctx context.Context, t *testing.T, client *agentsdk.Client) codersdk.ServiceBannerConfig {
func requireGetServiceBanner(ctx context.Context, t *testing.T, client *agentsdk.Client) codersdk.BannerConfig {
cc, err := client.ConnectRPC(ctx)
require.NoError(t, err)
defer func() {

View File

@ -1357,6 +1357,7 @@ export const getAppearance = async (): Promise<TypesGen.AppearanceConfig> => {
service_banner: {
enabled: false,
},
notification_banners: [],
};
}
throw ex;

View File

@ -5,13 +5,13 @@ import { getMetadataAsJSON } from "utils/metadata";
import { cachedQuery } from "./util";
const initialAppearanceData = getMetadataAsJSON<AppearanceConfig>("appearance");
const appearanceConfigKey = ["appearance"] as const;
export const appearanceConfigKey = ["appearance"] as const;
export const appearance = (): UseQueryOptions<AppearanceConfig> => {
// We either have our initial data or should immediately fetch and never again!
return cachedQuery({
initialData: initialAppearanceData,
queryKey: ["appearance"],
queryKey: appearanceConfigKey,
queryFn: () => API.getAppearance(),
});
};

View File

@ -48,7 +48,8 @@ export interface AppHostResponse {
export interface AppearanceConfig {
readonly application_name: string;
readonly logo_url: string;
readonly service_banner: ServiceBannerConfig;
readonly service_banner: BannerConfig;
readonly notification_banners: readonly BannerConfig[];
readonly support_links?: readonly LinkConfig[];
}
@ -157,6 +158,13 @@ export interface AvailableExperiments {
readonly safe: readonly Experiment[];
}
// From codersdk/deployment.go
export interface BannerConfig {
readonly enabled: boolean;
readonly message?: string;
readonly background_color?: string;
}
// From codersdk/deployment.go
export interface BuildInfoResponse {
readonly external_url: string;
@ -1281,7 +1289,8 @@ export interface UpdateActiveTemplateVersion {
export interface UpdateAppearanceConfig {
readonly application_name: string;
readonly logo_url: string;
readonly service_banner: ServiceBannerConfig;
readonly service_banner: BannerConfig;
readonly notification_banners: readonly BannerConfig[];
}
// From codersdk/updatecheck.go

View File

@ -7,6 +7,7 @@ import { Outlet } from "react-router-dom";
import { Loader } from "components/Loader/Loader";
import { useAuthenticated } from "contexts/auth/RequireAuth";
import { LicenseBanner } from "modules/dashboard/LicenseBanner/LicenseBanner";
import { NotificationBanners } from "modules/dashboard/NotificationBanners/NotificationBanners";
import { ServiceBanner } from "modules/dashboard/ServiceBanner/ServiceBanner";
import { dashboardContentBottomPadding } from "theme/constants";
import { docs } from "utils/docs";
@ -21,8 +22,9 @@ export const DashboardLayout: FC = () => {
return (
<>
<ServiceBanner />
{canViewDeployment && <LicenseBanner />}
<ServiceBanner />
<NotificationBanners />
<div
css={{

View File

@ -0,0 +1,24 @@
import type { Meta, StoryObj } from "@storybook/react";
import { NotificationBannerView } from "./NotificationBannerView";
const meta: Meta<typeof NotificationBannerView> = {
title: "modules/dashboard/NotificationBannerView",
component: NotificationBannerView,
};
export default meta;
type Story = StoryObj<typeof NotificationBannerView>;
export const Production: Story = {
args: {
message: "weeeee",
backgroundColor: "#FFFFFF",
},
};
export const Preview: Story = {
args: {
message: "weeeee",
backgroundColor: "#000000",
},
};

View File

@ -0,0 +1,48 @@
import { css, type Interpolation, type Theme } from "@emotion/react";
import type { FC } from "react";
import { InlineMarkdown } from "components/Markdown/Markdown";
import { readableForegroundColor } from "utils/colors";
export interface NotificationBannerViewProps {
message?: string;
backgroundColor?: string;
}
export const NotificationBannerView: FC<NotificationBannerViewProps> = ({
message,
backgroundColor,
}) => {
if (message === undefined || backgroundColor === undefined) {
return null;
}
return (
<div css={[styles.banner, { backgroundColor }]} className="service-banner">
<div
css={[
styles.wrapper,
{ color: readableForegroundColor(backgroundColor) },
]}
>
<InlineMarkdown>{message}</InlineMarkdown>
</div>
</div>
);
};
const styles = {
banner: css`
padding: 12px;
display: flex;
align-items: center;
`,
wrapper: css`
margin-right: auto;
margin-left: auto;
font-weight: 400;
& a {
color: inherit;
}
`,
} satisfies Record<string, Interpolation<Theme>>;

View File

@ -0,0 +1,22 @@
import type { FC } from "react";
import { useDashboard } from "modules/dashboard/useDashboard";
import { NotificationBannerView } from "./NotificationBannerView";
export const NotificationBanners: FC = () => {
const dashboard = useDashboard();
const notificationBanners = dashboard.appearance.config.notification_banners;
return (
<>
{notificationBanners
.filter((banner) => banner.enabled)
.map(({ message, background_color }) => (
<NotificationBannerView
key={message}
message={message}
backgroundColor={background_color}
/>
))}
</>
);
};

View File

@ -2,7 +2,7 @@ import type { FC } from "react";
import { Helmet } from "react-helmet-async";
import { useMutation, useQueryClient } from "react-query";
import { getErrorMessage } from "api/errors";
import { updateAppearance } from "api/queries/appearance";
import { appearanceConfigKey, updateAppearance } from "api/queries/appearance";
import type { UpdateAppearanceConfig } from "api/typesGenerated";
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
import { useDashboard } from "modules/dashboard/useDashboard";
@ -20,7 +20,7 @@ const AppearanceSettingsPage: FC = () => {
const onSaveAppearance = async (
newConfig: Partial<UpdateAppearanceConfig>,
preview: boolean,
preview: boolean = false,
) => {
const newAppearance = { ...appearance.config, ...newConfig };
if (preview) {
@ -30,6 +30,7 @@ const AppearanceSettingsPage: FC = () => {
try {
await updateAppearanceMutation.mutateAsync(newAppearance);
await queryClient.invalidateQueries(appearanceConfigKey);
displaySuccess("Successfully updated appearance settings!");
} catch (error) {
displayError(

View File

@ -13,6 +13,13 @@ const meta: Meta<typeof AppearanceSettingsPageView> = {
message: "hello world",
background_color: "white",
},
notification_banners: [
{
enabled: true,
message: "hello world",
background_color: "white",
},
],
},
isEntitled: false,
},

View File

@ -8,9 +8,10 @@ import TextField from "@mui/material/TextField";
import { useFormik } from "formik";
import { type FC, useState } from "react";
import { BlockPicker } from "react-color";
import type { UpdateAppearanceConfig } from "api/typesGenerated";
import type { BannerConfig, UpdateAppearanceConfig } from "api/typesGenerated";
import {
Badges,
DeprecatedBadge,
DisabledBadge,
EnterpriseBadge,
EntitledBadge,
@ -20,13 +21,14 @@ import colors from "theme/tailwindColors";
import { getFormHelpers } from "utils/formUtils";
import { Fieldset } from "../Fieldset";
import { Header } from "../Header";
import { NotificationBannerItem } from "./NotificationBannerItem";
export type AppearanceSettingsPageViewProps = {
appearance: UpdateAppearanceConfig;
isEntitled: boolean;
onSaveAppearance: (
newConfig: Partial<UpdateAppearanceConfig>,
preview: boolean,
preview?: boolean,
) => void;
};
@ -80,6 +82,36 @@ export const AppearanceSettingsPageView: FC<
serviceBannerForm.values.background_color,
);
const [notificationBanners, setNotificationBanners] = useState(
appearance.notification_banners,
);
const addNotificationBannerItem = () => {
setNotificationBanners((banners) => [
...banners,
{ enabled: true, message: "foob", background_color: "#004852" },
]);
};
const updateNotificationBannerItem = (
i: number,
banner: Partial<BannerConfig>,
) => {
setNotificationBanners((banners) => {
const newBanners = [...banners];
newBanners[i] = { ...banners[i], ...banner };
return newBanners;
});
};
const removeNotificationBannerItem = (i: number) => {
setNotificationBanners((banners) => {
const newBanners = [...banners];
newBanners.splice(i, 1);
return newBanners;
});
};
return (
<>
<Header
@ -160,7 +192,11 @@ export const AppearanceSettingsPageView: FC<
</Fieldset>
<Fieldset
title="Service Banner"
title={
<Stack direction="row">
Service Banner <DeprecatedBadge />
</Stack>
}
subtitle="Configure a banner that displays a message to all users."
onSubmit={serviceBannerForm.handleSubmit}
button={
@ -235,7 +271,7 @@ export const AppearanceSettingsPageView: FC<
</Stack>
<Stack spacing={0}>
<h3>{"Background Color"}</h3>
<h3>Background Color</h3>
<BlockPicker
color={backgroundColor}
onChange={async (color) => {
@ -276,6 +312,41 @@ export const AppearanceSettingsPageView: FC<
</Stack>
)}
</Fieldset>
<Fieldset
title="Notification Banners"
onSubmit={() =>
onSaveAppearance({
notification_banners: notificationBanners,
})
}
>
<>
<Button onClick={() => addNotificationBannerItem()}>+</Button>
<Stack spacing={4}>
{notificationBanners
.filter((banner) => banner.enabled)
.map((banner, i) => (
<NotificationBannerItem
key={i}
enabled={banner.enabled}
backgroundColor={banner.background_color}
message={banner.message}
onRemove={() => removeNotificationBannerItem(i)}
onUpdate={(banner: Partial<BannerConfig>) => {
const shouldPersist = "enabled" in banner;
updateNotificationBannerItem(i, banner);
if (shouldPersist) {
onSaveAppearance({
notification_banners: notificationBanners,
});
}
}}
/>
))}
</Stack>
</>
</Fieldset>
</>
);
};

View File

@ -0,0 +1,97 @@
import { useTheme } from "@emotion/react";
import Delete from "@mui/icons-material/Delete";
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
import type { FC } from "react";
import { BlockPicker } from "react-color";
import type { BannerConfig } from "api/typesGenerated";
import Switch from "@mui/material/Switch";
interface NotificationBannerItemProps {
enabled: boolean;
backgroundColor?: string;
message?: string;
onRemove: () => void;
onUpdate: (banner: Partial<BannerConfig>) => void;
}
export const NotificationBannerItem: FC<NotificationBannerItemProps> = ({
enabled,
backgroundColor,
message,
onRemove,
onUpdate,
}) => {
const theme = useTheme();
return (
<div>
<div>
<Switch
checked={enabled}
onChange={() => onUpdate({ enabled: !enabled })}
data-testid="switch-service-banner"
/>
<Button onClick={onRemove}>
<Delete />
</Button>
</div>
<div css={{ backgroundColor }}>{message}</div>
<TextField
// {...serviceBannerFieldHelpers("message", {
// helperText:
// ,
// })}
onChange={(event) => onUpdate({ message: event.target.value })}
defaultValue={message}
helperText="Markdown bold, italics, and links are supported."
fullWidth
label="Message"
multiline
inputProps={{
"aria-label": "Message",
}}
/>
<button
type="button"
css={{
backgroundColor,
width: 24,
height: 24,
outline: "none",
border: "none",
borderRadius: 4,
}}
></button>
<details>
<summary>Background Color</summary>
<BlockPicker
color={backgroundColor}
onChange={async (color) => {
// TODO: preview the color?
onUpdate({ background_color: color.hex });
}}
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",
},
},
}}
/>
</details>
</div>
);
};

View File

@ -2355,6 +2355,7 @@ export const MockAppearanceConfig: TypesGen.AppearanceConfig = {
service_banner: {
enabled: false,
},
notification_banners: [],
};
export const MockWorkspaceBuildParameter1: TypesGen.WorkspaceBuildParameter = {