diff --git a/cli/deployment/config.go b/cli/deployment/config.go index 41f53eb600fd2..d265ae67eeffe 100644 --- a/cli/deployment/config.go +++ b/cli/deployment/config.go @@ -570,6 +570,15 @@ func newConfig() *codersdk.DeploymentConfig { Flag: "disable-password-auth", Default: false, }, + Support: &codersdk.SupportConfig{ + Links: &codersdk.DeploymentConfigField[[]codersdk.LinkConfig]{ + Name: "Support links", + Usage: "Use custom support links", + Flag: "support-links", + Default: []codersdk.LinkConfig{}, + Enterprise: true, + }, + }, } } @@ -649,6 +658,10 @@ func setConfig(prefix string, vip *viper.Viper, target interface{}) { // Do not bind to CODER_GITAUTH, instead bind to CODER_GITAUTH_0_*, etc. values := readSliceFromViper[codersdk.GitAuthConfig](vip, prefix, value) val.FieldByName("Value").Set(reflect.ValueOf(values)) + case []codersdk.LinkConfig: + // Do not bind to CODER_SUPPORT_LINKS, instead bind to CODER_SUPPORT_LINKS_0_*, etc. + values := readSliceFromViper[codersdk.LinkConfig](vip, prefix, value) + val.FieldByName("Value").Set(reflect.ValueOf(values)) default: panic(fmt.Sprintf("unsupported type %T", value)) } @@ -824,6 +837,8 @@ func setFlags(prefix string, flagset *pflag.FlagSet, vip *viper.Viper, target in _ = flagset.DurationP(flg, shorthand, vip.GetDuration(prefix), usage) case []string: _ = flagset.StringSliceP(flg, shorthand, vip.GetStringSlice(prefix), usage) + case []codersdk.LinkConfig: + // Ignore this one! case []codersdk.GitAuthConfig: // Ignore this one! default: diff --git a/cli/deployment/config_test.go b/cli/deployment/config_test.go index d6d5b93d98043..37dbc9a9e42e1 100644 --- a/cli/deployment/config_test.go +++ b/cli/deployment/config_test.go @@ -222,6 +222,29 @@ func TestConfig(t *testing.T) { Regex: "gitlab.com", }}, config.GitAuth.Value) }, + }, { + Name: "Support links", + Env: map[string]string{ + "CODER_SUPPORT_LINKS_0_NAME": "First link", + "CODER_SUPPORT_LINKS_0_TARGET": "http://target-link-1", + "CODER_SUPPORT_LINKS_0_ICON": "bug", + + "CODER_SUPPORT_LINKS_1_NAME": "Second link", + "CODER_SUPPORT_LINKS_1_TARGET": "http://target-link-2", + "CODER_SUPPORT_LINKS_1_ICON": "chat", + }, + Valid: func(config *codersdk.DeploymentConfig) { + require.Len(t, config.Support.Links.Value, 2) + require.Equal(t, []codersdk.LinkConfig{{ + Name: "First link", + Target: "http://target-link-1", + Icon: "bug", + }, { + Name: "Second link", + Target: "http://target-link-2", + Icon: "chat", + }}, config.Support.Links.Value) + }, }, { Name: "Wrong env must not break default values", Env: map[string]string{ diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index d4e1236849534..5912070e2e322 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -93,7 +93,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.AppearanceConfig" + "$ref": "#/definitions/codersdk.UpdateAppearanceConfig" } } ], @@ -101,7 +101,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.AppearanceConfig" + "$ref": "#/definitions/codersdk.UpdateAppearanceConfig" } } } @@ -5379,6 +5379,12 @@ const docTemplate = `{ }, "service_banner": { "$ref": "#/definitions/codersdk.ServiceBannerConfig" + }, + "support_links": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.LinkConfig" + } } } }, @@ -6196,6 +6202,9 @@ const docTemplate = `{ "strict_transport_security_options": { "$ref": "#/definitions/codersdk.DeploymentConfigField-array_string" }, + "support": { + "$ref": "#/definitions/codersdk.SupportConfig" + }, "swagger": { "$ref": "#/definitions/codersdk.SwaggerConfig" }, @@ -6254,6 +6263,44 @@ const docTemplate = `{ } } }, + "codersdk.DeploymentConfigField-array_codersdk_LinkConfig": { + "type": "object", + "properties": { + "default": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.LinkConfig" + } + }, + "enterprise": { + "type": "boolean" + }, + "flag": { + "type": "string" + }, + "hidden": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "secret": { + "type": "boolean" + }, + "shorthand": { + "type": "string" + }, + "usage": { + "type": "string" + }, + "value": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.LinkConfig" + } + } + } + }, "codersdk.DeploymentConfigField-array_string": { "type": "object", "properties": { @@ -6651,6 +6698,20 @@ const docTemplate = `{ } } }, + "codersdk.LinkConfig": { + "type": "object", + "properties": { + "icon": { + "type": "string" + }, + "name": { + "type": "string" + }, + "target": { + "type": "string" + } + } + }, "codersdk.LogLevel": { "type": "string", "enum": [ @@ -7362,6 +7423,14 @@ const docTemplate = `{ } } }, + "codersdk.SupportConfig": { + "type": "object", + "properties": { + "links": { + "$ref": "#/definitions/codersdk.DeploymentConfigField-array_codersdk_LinkConfig" + } + } + }, "codersdk.SwaggerConfig": { "type": "object", "properties": { @@ -7795,6 +7864,17 @@ const docTemplate = `{ } } }, + "codersdk.UpdateAppearanceConfig": { + "type": "object", + "properties": { + "logo_url": { + "type": "string" + }, + "service_banner": { + "$ref": "#/definitions/codersdk.ServiceBannerConfig" + } + } + }, "codersdk.UpdateCheckResponse": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 894a2d0b6cafd..b21f7e6b86166 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -71,7 +71,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.AppearanceConfig" + "$ref": "#/definitions/codersdk.UpdateAppearanceConfig" } } ], @@ -79,7 +79,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.AppearanceConfig" + "$ref": "#/definitions/codersdk.UpdateAppearanceConfig" } } } @@ -4760,6 +4760,12 @@ }, "service_banner": { "$ref": "#/definitions/codersdk.ServiceBannerConfig" + }, + "support_links": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.LinkConfig" + } } } }, @@ -5516,6 +5522,9 @@ "strict_transport_security_options": { "$ref": "#/definitions/codersdk.DeploymentConfigField-array_string" }, + "support": { + "$ref": "#/definitions/codersdk.SupportConfig" + }, "swagger": { "$ref": "#/definitions/codersdk.SwaggerConfig" }, @@ -5574,6 +5583,44 @@ } } }, + "codersdk.DeploymentConfigField-array_codersdk_LinkConfig": { + "type": "object", + "properties": { + "default": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.LinkConfig" + } + }, + "enterprise": { + "type": "boolean" + }, + "flag": { + "type": "string" + }, + "hidden": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "secret": { + "type": "boolean" + }, + "shorthand": { + "type": "string" + }, + "usage": { + "type": "string" + }, + "value": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.LinkConfig" + } + } + } + }, "codersdk.DeploymentConfigField-array_string": { "type": "object", "properties": { @@ -5961,6 +6008,20 @@ } } }, + "codersdk.LinkConfig": { + "type": "object", + "properties": { + "icon": { + "type": "string" + }, + "name": { + "type": "string" + }, + "target": { + "type": "string" + } + } + }, "codersdk.LogLevel": { "type": "string", "enum": ["trace", "debug", "info", "warn", "error"], @@ -6608,6 +6669,14 @@ } } }, + "codersdk.SupportConfig": { + "type": "object", + "properties": { + "links": { + "$ref": "#/definitions/codersdk.DeploymentConfigField-array_codersdk_LinkConfig" + } + } + }, "codersdk.SwaggerConfig": { "type": "object", "properties": { @@ -7011,6 +7080,17 @@ } } }, + "codersdk.UpdateAppearanceConfig": { + "type": "object", + "properties": { + "logo_url": { + "type": "string" + }, + "service_banner": { + "$ref": "#/definitions/codersdk.ServiceBannerConfig" + } + } + }, "codersdk.UpdateCheckResponse": { "type": "object", "properties": { diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 69bb6b6b3f0e7..7fb80303e6b56 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -153,6 +153,8 @@ type DeploymentConfig struct { Address *DeploymentConfigField[string] `json:"address" typescript:",notnull"` // DEPRECATED: Use Experiments instead. Experimental *DeploymentConfigField[bool] `json:"experimental" typescript:",notnull"` + + Support *SupportConfig `json:"support" typescript:",notnull"` } type DERP struct { @@ -276,8 +278,18 @@ type DangerousConfig struct { AllowPathAppSiteOwnerAccess *DeploymentConfigField[bool] `json:"allow_path_app_site_owner_access" typescript:",notnull"` } +type SupportConfig struct { + Links *DeploymentConfigField[[]LinkConfig] `json:"links" typescript:",notnull"` +} + +type LinkConfig struct { + Name string `json:"name"` + Target string `json:"target"` + Icon string `json:"icon"` +} + type Flaggable interface { - string | time.Duration | bool | int | []string | []GitAuthConfig + string | time.Duration | bool | int | []string | []GitAuthConfig | []LinkConfig } type DeploymentConfigField[T Flaggable] struct { @@ -348,6 +360,12 @@ func (c *Client) DeploymentConfig(ctx context.Context) (DeploymentConfig, error) type AppearanceConfig struct { LogoURL string `json:"logo_url"` ServiceBanner ServiceBannerConfig `json:"service_banner"` + SupportLinks []LinkConfig `json:"support_links,omitempty"` +} + +type UpdateAppearanceConfig struct { + LogoURL string `json:"logo_url"` + ServiceBanner ServiceBannerConfig `json:"service_banner"` } type ServiceBannerConfig struct { @@ -371,7 +389,7 @@ func (c *Client) Appearance(ctx context.Context) (AppearanceConfig, error) { return cfg, json.NewDecoder(res.Body).Decode(&cfg) } -func (c *Client) UpdateAppearance(ctx context.Context, appearance AppearanceConfig) error { +func (c *Client) UpdateAppearance(ctx context.Context, appearance UpdateAppearanceConfig) error { res, err := c.Request(ctx, http.MethodPut, "/api/v2/appearance", appearance) if err != nil { return err diff --git a/docs/api/enterprise.md b/docs/api/enterprise.md index be461cc606e44..87eae9c4f57c1 100644 --- a/docs/api/enterprise.md +++ b/docs/api/enterprise.md @@ -24,7 +24,14 @@ curl -X GET http://coder-server:8080/api/v2/appearance \ "background_color": "string", "enabled": true, "message": "string" - } + }, + "support_links": [ + { + "icon": "string", + "name": "string", + "target": "string" + } + ] } ``` @@ -65,9 +72,9 @@ curl -X PUT http://coder-server:8080/api/v2/appearance \ ### Parameters -| Name | In | Type | Required | Description | -| ------ | ---- | ---------------------------------------------------------------- | -------- | ------------------------- | -| `body` | body | [codersdk.AppearanceConfig](schemas.md#codersdkappearanceconfig) | true | Update appearance request | +| Name | In | Type | Required | Description | +| ------ | ---- | ---------------------------------------------------------------------------- | -------- | ------------------------- | +| `body` | body | [codersdk.UpdateAppearanceConfig](schemas.md#codersdkupdateappearanceconfig) | true | Update appearance request | ### Example responses @@ -86,9 +93,9 @@ curl -X PUT http://coder-server:8080/api/v2/appearance \ ### Responses -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------- | ---------------------------------------------------------------- | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.AppearanceConfig](schemas.md#codersdkappearanceconfig) | +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.UpdateAppearanceConfig](schemas.md#codersdkupdateappearanceconfig) | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/api/general.md b/docs/api/general.md index 15854e7cfec86..8102f24af5df1 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -879,6 +879,31 @@ curl -X GET http://coder-server:8080/api/v2/config/deployment \ "usage": "string", "value": ["string"] }, + "support": { + "links": { + "default": [ + { + "icon": "string", + "name": "string", + "target": "string" + } + ], + "enterprise": true, + "flag": "string", + "hidden": true, + "name": "string", + "secret": true, + "shorthand": "string", + "usage": "string", + "value": [ + { + "icon": "string", + "name": "string", + "target": "string" + } + ] + } + }, "swagger": { "enable": { "default": true, diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 948a0e6304197..9ca068a81aacb 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -440,7 +440,14 @@ "background_color": "string", "enabled": true, "message": "string" - } + }, + "support_links": [ + { + "icon": "string", + "name": "string", + "target": "string" + } + ] } ``` @@ -450,6 +457,7 @@ | ---------------- | ------------------------------------------------------------ | -------- | ------------ | ----------- | | `logo_url` | string | false | | | | `service_banner` | [codersdk.ServiceBannerConfig](#codersdkservicebannerconfig) | false | | | +| `support_links` | array of [codersdk.LinkConfig](#codersdklinkconfig) | false | | | ## codersdk.AssignableRoles @@ -2285,6 +2293,31 @@ CreateParameterRequest is a structure used to create a new parameter value for a "usage": "string", "value": ["string"] }, + "support": { + "links": { + "default": [ + { + "icon": "string", + "name": "string", + "target": "string" + } + ], + "enterprise": true, + "flag": "string", + "hidden": true, + "name": "string", + "secret": true, + "shorthand": "string", + "usage": "string", + "value": [ + { + "icon": "string", + "name": "string", + "target": "string" + } + ] + } + }, "swagger": { "enable": { "default": true, @@ -2546,6 +2579,7 @@ CreateParameterRequest is a structure used to create a new parameter value for a | `ssh_keygen_algorithm` | [codersdk.DeploymentConfigField-string](#codersdkdeploymentconfigfield-string) | false | | | | `strict_transport_security` | [codersdk.DeploymentConfigField-int](#codersdkdeploymentconfigfield-int) | false | | | | `strict_transport_security_options` | [codersdk.DeploymentConfigField-array_string](#codersdkdeploymentconfigfield-array_string) | false | | | +| `support` | [codersdk.SupportConfig](#codersdksupportconfig) | false | | | | `swagger` | [codersdk.SwaggerConfig](#codersdkswaggerconfig) | false | | | | `telemetry` | [codersdk.TelemetryConfig](#codersdktelemetryconfig) | false | | | | `tls` | [codersdk.TLSConfig](#codersdktlsconfig) | false | | | @@ -2607,6 +2641,48 @@ CreateParameterRequest is a structure used to create a new parameter value for a | `usage` | string | false | | | | `value` | array of [codersdk.GitAuthConfig](#codersdkgitauthconfig) | false | | | +## codersdk.DeploymentConfigField-array_codersdk_LinkConfig + +```json +{ + "default": [ + { + "icon": "string", + "name": "string", + "target": "string" + } + ], + "enterprise": true, + "flag": "string", + "hidden": true, + "name": "string", + "secret": true, + "shorthand": "string", + "usage": "string", + "value": [ + { + "icon": "string", + "name": "string", + "target": "string" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------------ | --------------------------------------------------- | -------- | ------------ | ----------- | +| `default` | array of [codersdk.LinkConfig](#codersdklinkconfig) | false | | | +| `enterprise` | boolean | false | | | +| `flag` | string | false | | | +| `hidden` | boolean | false | | | +| `name` | string | false | | | +| `secret` | boolean | false | | | +| `shorthand` | string | false | | | +| `usage` | string | false | | | +| `value` | array of [codersdk.LinkConfig](#codersdklinkconfig) | false | | | + ## codersdk.DeploymentConfigField-array_string ```json @@ -3043,6 +3119,24 @@ CreateParameterRequest is a structure used to create a new parameter value for a | `uploaded_at` | string | false | | | | `uuid` | string | false | | | +## codersdk.LinkConfig + +```json +{ + "icon": "string", + "name": "string", + "target": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------- | ------ | -------- | ------------ | ----------- | +| `icon` | string | false | | | +| `name` | string | false | | | +| `target` | string | false | | | + ## codersdk.LogLevel ```json @@ -4120,6 +4214,42 @@ Parameter represents a set value for the scope. | `enabled` | boolean | false | | | | `message` | string | false | | | +## codersdk.SupportConfig + +```json +{ + "links": { + "default": [ + { + "icon": "string", + "name": "string", + "target": "string" + } + ], + "enterprise": true, + "flag": "string", + "hidden": true, + "name": "string", + "secret": true, + "shorthand": "string", + "usage": "string", + "value": [ + { + "icon": "string", + "name": "string", + "target": "string" + } + ] + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------- | -------------------------------------------------------------------------------------------------------------------- | -------- | ------------ | ----------- | +| `links` | [codersdk.DeploymentConfigField-array_codersdk_LinkConfig](#codersdkdeploymentconfigfield-array_codersdk_linkconfig) | false | | | + ## codersdk.SwaggerConfig ```json @@ -4757,6 +4887,26 @@ Parameter represents a set value for the scope. | ---- | ------ | -------- | ------------ | ----------- | | `id` | string | true | | | +## codersdk.UpdateAppearanceConfig + +```json +{ + "logo_url": "string", + "service_banner": { + "background_color": "string", + "enabled": true, + "message": "string" + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ---------------- | ------------------------------------------------------------ | -------- | ------------ | ----------- | +| `logo_url` | string | false | | | +| `service_banner` | [codersdk.ServiceBannerConfig](#codersdkservicebannerconfig) | false | | | + ## codersdk.UpdateCheckResponse ```json diff --git a/enterprise/coderd/appearance.go b/enterprise/coderd/appearance.go index e60e7a2ed5388..7c230218e868f 100644 --- a/enterprise/coderd/appearance.go +++ b/enterprise/coderd/appearance.go @@ -15,6 +15,24 @@ import ( "github.com/coder/coder/codersdk" ) +var DefaultSupportLinks = []codersdk.LinkConfig{ + { + Name: "Documentation", + Target: "https://coder.com/docs/coder-oss", + Icon: "docs", + }, + { + Name: "Report a bug", + Target: "https://github.com/coder/coder/issues/new?labels=needs+grooming&body={CODER_BUILD_INFO}", + Icon: "bug", + }, + { + Name: "Join the Coder Discord", + Target: "https://coder.com/chat?utm_source=coder&utm_medium=coder&utm_campaign=server-footer", + Icon: "chat", + }, +} + // @Summary Get appearance // @ID get-appearance // @Security CoderSessionToken @@ -30,7 +48,9 @@ func (api *API) appearance(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() if !isEntitled { - httpapi.Write(ctx, rw, http.StatusOK, codersdk.AppearanceConfig{}) + httpapi.Write(ctx, rw, http.StatusOK, codersdk.AppearanceConfig{ + SupportLinks: DefaultSupportLinks, + }) return } @@ -67,6 +87,12 @@ func (api *API) appearance(rw http.ResponseWriter, r *http.Request) { } } + if len(api.DeploymentConfig.Support.Links.Value) == 0 { + cfg.SupportLinks = DefaultSupportLinks + } else { + cfg.SupportLinks = api.DeploymentConfig.Support.Links.Value + } + httpapi.Write(r.Context(), rw, http.StatusOK, cfg) } @@ -87,8 +113,8 @@ func validateHexColor(color string) error { // @Accept json // @Produce json // @Tags Enterprise -// @Param request body codersdk.AppearanceConfig true "Update appearance request" -// @Success 200 {object} codersdk.AppearanceConfig +// @Param request body codersdk.UpdateAppearanceConfig true "Update appearance request" +// @Success 200 {object} codersdk.UpdateAppearanceConfig // @Router /appearance [put] func (api *API) putAppearance(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -100,7 +126,7 @@ func (api *API) putAppearance(rw http.ResponseWriter, r *http.Request) { return } - var appearance codersdk.AppearanceConfig + var appearance codersdk.UpdateAppearanceConfig if !httpapi.Read(ctx, rw, r, &appearance) { return } diff --git a/enterprise/coderd/appearance_test.go b/enterprise/coderd/appearance_test.go index a1192c784aa4d..c1451c1193499 100644 --- a/enterprise/coderd/appearance_test.go +++ b/enterprise/coderd/appearance_test.go @@ -10,6 +10,7 @@ import ( "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/codersdk" + "github.com/coder/coder/enterprise/coderd" "github.com/coder/coder/enterprise/coderd/coderdenttest" "github.com/coder/coder/enterprise/coderd/license" "github.com/coder/coder/testutil" @@ -43,16 +44,20 @@ func TestServiceBanners(t *testing.T) { basicUserClient, _ := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID) + uac := codersdk.UpdateAppearanceConfig{ + ServiceBanner: sb.ServiceBanner, + } // Regular user should be unable to set the banner - sb.ServiceBanner.Enabled = true - err = basicUserClient.UpdateAppearance(ctx, sb) + uac.ServiceBanner.Enabled = true + + err = basicUserClient.UpdateAppearance(ctx, uac) 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 := uac wantBanner.ServiceBanner.Enabled = true wantBanner.ServiceBanner.Message = "Hey" wantBanner.ServiceBanner.BackgroundColor = "#00FF00" @@ -60,10 +65,67 @@ func TestServiceBanners(t *testing.T) { require.NoError(t, err) gotBanner, err := adminClient.Appearance(ctx) require.NoError(t, err) - require.Equal(t, wantBanner, gotBanner) + gotBanner.SupportLinks = nil // clean "support links" before comparison + require.Equal(t, wantBanner.ServiceBanner, gotBanner.ServiceBanner) // But even an admin can't give a bad color wantBanner.ServiceBanner.BackgroundColor = "#bad color" err = adminClient.UpdateAppearance(ctx, wantBanner) require.Error(t, err) } + +func TestCustomSupportLinks(t *testing.T) { + t.Parallel() + + supportLinks := []codersdk.LinkConfig{ + { + Name: "First link", + Target: "http://first-link-1", + Icon: "chat", + }, + { + Name: "Second link", + Target: "http://second-link-2", + Icon: "bug", + }, + } + cfg := coderdtest.DeploymentConfig(t) + cfg.Support = new(codersdk.SupportConfig) + cfg.Support.Links = &codersdk.DeploymentConfigField[[]codersdk.LinkConfig]{ + Value: supportLinks, + } + + client := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentConfig: cfg, + }, + }) + coderdtest.CreateFirstUser(t, client) + coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAppearance: 1, + }, + }) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium) + defer cancel() + + appearance, err := client.Appearance(ctx) + require.NoError(t, err) + require.Equal(t, supportLinks, appearance.SupportLinks) +} + +func TestDefaultSupportLinks(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + coderdtest.CreateFirstUser(t, client) + // Don't need to set the license, as default links are passed without it. + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium) + defer cancel() + + appearance, err := client.Appearance(ctx) + require.NoError(t, err) + require.Equal(t, coderd.DefaultSupportLinks, appearance.SupportLinks) +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index c7159fb50a8f8..46b1c19de5c71 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -34,6 +34,7 @@ export interface AppHostResponse { export interface AppearanceConfig { readonly logo_url: string readonly service_banner: ServiceBannerConfig + readonly support_links?: LinkConfig[] } // From codersdk/roles.go @@ -334,6 +335,7 @@ export interface DeploymentConfig { readonly disable_password_auth: DeploymentConfigField readonly address: DeploymentConfigField readonly experimental: DeploymentConfigField + readonly support: SupportConfig } // From codersdk/deployment.go @@ -434,6 +436,13 @@ export interface License { readonly claims: Record } +// From codersdk/deployment.go +export interface LinkConfig { + readonly name: string + readonly target: string + readonly icon: string +} + // From codersdk/deployment.go export interface LoggingConfig { readonly human: DeploymentConfigField @@ -657,6 +666,11 @@ export interface ServiceBannerConfig { readonly background_color?: string } +// From codersdk/deployment.go +export interface SupportConfig { + readonly links: DeploymentConfigField +} + // From codersdk/deployment.go export interface SwaggerConfig { readonly enable: DeploymentConfigField @@ -813,6 +827,12 @@ export interface UpdateActiveTemplateVersion { readonly id: string } +// From codersdk/deployment.go +export interface UpdateAppearanceConfig { + readonly logo_url: string + readonly service_banner: ServiceBannerConfig +} + // From codersdk/updatecheck.go export interface UpdateCheckResponse { readonly current: boolean @@ -1326,4 +1346,10 @@ export const WorkspaceTransitions: WorkspaceTransition[] = [ ] // From codersdk/deployment.go -export type Flaggable = string | number | boolean | string[] | GitAuthConfig[] +export type Flaggable = + | string + | number + | boolean + | string[] + | GitAuthConfig[] + | LinkConfig[] diff --git a/site/src/components/Navbar/Navbar.tsx b/site/src/components/Navbar/Navbar.tsx index c7090178f2d5d..f46e6bfccbf43 100644 --- a/site/src/components/Navbar/Navbar.tsx +++ b/site/src/components/Navbar/Navbar.tsx @@ -22,6 +22,7 @@ export const Navbar: FC = () => { user={me} logo_url={appearance.config.logo_url} buildInfo={buildInfo} + supportLinks={appearance.config.support_links} onSignOut={onSignOut} canViewAuditLog={canViewAuditLog} canViewDeployment={canViewDeployment} diff --git a/site/src/components/NavbarView/NavbarView.tsx b/site/src/components/NavbarView/NavbarView.tsx index 44123fa0100f4..039cb4126686c 100644 --- a/site/src/components/NavbarView/NavbarView.tsx +++ b/site/src/components/NavbarView/NavbarView.tsx @@ -19,6 +19,7 @@ export interface NavbarViewProps { logo_url?: string user?: TypesGen.User buildInfo?: TypesGen.BuildInfoResponse + supportLinks?: TypesGen.LinkConfig[] onSignOut: () => void canViewAuditLog: boolean canViewDeployment: boolean @@ -86,6 +87,7 @@ export const NavbarView: React.FC> = ({ user, logo_url, buildInfo, + supportLinks, onSignOut, canViewAuditLog, canViewDeployment, @@ -147,6 +149,7 @@ export const NavbarView: React.FC> = ({ )} diff --git a/site/src/components/UserDropdown/UserDropdown.test.tsx b/site/src/components/UserDropdown/UserDropdown.test.tsx index c12a79a1da165..9c05202437cdc 100644 --- a/site/src/components/UserDropdown/UserDropdown.test.tsx +++ b/site/src/components/UserDropdown/UserDropdown.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, screen } from "@testing-library/react" -import { MockUser } from "../../testHelpers/entities" +import { MockSupportLinks, MockUser } from "../../testHelpers/entities" import { render } from "../../testHelpers/renderHelpers" import { Language } from "../UserDropdownContent/UserDropdownContent" import { UserDropdown, UserDropdownProps } from "./UsersDropdown" @@ -8,6 +8,7 @@ const renderAndClick = async (props: Partial = {}) => { render( , ) @@ -20,7 +21,9 @@ describe("UserDropdown", () => { it("opens the menu", async () => { await renderAndClick() expect(screen.getByText(Language.accountLabel)).toBeDefined() - expect(screen.getByText(Language.docsLabel)).toBeDefined() + expect(screen.getByText(MockSupportLinks[0].name)).toBeDefined() + expect(screen.getByText(MockSupportLinks[1].name)).toBeDefined() + expect(screen.getByText(MockSupportLinks[2].name)).toBeDefined() expect(screen.getByText(Language.signOutLabel)).toBeDefined() }) }) diff --git a/site/src/components/UserDropdown/UsersDropdown.tsx b/site/src/components/UserDropdown/UsersDropdown.tsx index e9650fc29d8dd..3d670f3be3273 100644 --- a/site/src/components/UserDropdown/UsersDropdown.tsx +++ b/site/src/components/UserDropdown/UsersDropdown.tsx @@ -13,12 +13,14 @@ import { UserDropdownContent } from "../UserDropdownContent/UserDropdownContent" export interface UserDropdownProps { user: TypesGen.User buildInfo?: TypesGen.BuildInfoResponse + supportLinks?: TypesGen.LinkConfig[] onSignOut: () => void } export const UserDropdown: FC> = ({ buildInfo, user, + supportLinks, onSignOut, }: UserDropdownProps) => { const styles = useStyles() @@ -69,6 +71,7 @@ export const UserDropdown: FC> = ({ diff --git a/site/src/components/UserDropdownContent/UserDropdownContent.test.tsx b/site/src/components/UserDropdownContent/UserDropdownContent.test.tsx index 0e9442e20a0c7..3eb538c6b6921 100644 --- a/site/src/components/UserDropdownContent/UserDropdownContent.test.tsx +++ b/site/src/components/UserDropdownContent/UserDropdownContent.test.tsx @@ -1,5 +1,9 @@ import { screen } from "@testing-library/react" -import { MockBuildInfo, MockUser } from "../../testHelpers/entities" +import { + MockBuildInfo, + MockSupportLinks, + MockUser, +} from "../../testHelpers/entities" import { render } from "../../testHelpers/renderHelpers" import { Language, UserDropdownContent } from "./UserDropdownContent" @@ -21,16 +25,23 @@ describe("UserDropdownContent", () => { , ) expect(screen.getByText(Language.accountLabel)).toBeDefined() - expect(screen.getByText(Language.docsLabel)).toBeDefined() expect(screen.getByText(Language.signOutLabel)).toBeDefined() - expect(screen.getByText(Language.bugLabel)).toBeDefined() - expect(screen.getByText(Language.discordLabel)).toBeDefined() expect(screen.getByText(Language.copyrightText)).toBeDefined() + expect(screen.getByText(MockSupportLinks[0].name)).toBeDefined() + expect(screen.getByText(MockSupportLinks[1].name)).toBeDefined() + expect(screen.getByText(MockSupportLinks[2].name)).toBeDefined() + expect( + screen.getByText(MockSupportLinks[2].name).closest("a"), + ).toHaveAttribute( + "href", + "https://github.com/coder/coder/issues/new?labels=needs+grooming&body=Version%3A%20%5B%60v99.999.9999%2Bc9cdf14%60%5D(file%3A%2F%2F%2Fmock-url)", + ) expect(screen.getByText(MockBuildInfo.version)).toBeDefined() }) diff --git a/site/src/components/UserDropdownContent/UserDropdownContent.tsx b/site/src/components/UserDropdownContent/UserDropdownContent.tsx index b69b3a6585343..d51dfcab102f5 100644 --- a/site/src/components/UserDropdownContent/UserDropdownContent.tsx +++ b/site/src/components/UserDropdownContent/UserDropdownContent.tsx @@ -15,16 +15,14 @@ import { combineClasses } from "util/combineClasses" export const Language = { accountLabel: "Account", - docsLabel: "Documentation", signOutLabel: "Sign Out", - bugLabel: "Report a Bug", - discordLabel: "Join the Coder Discord", copyrightText: `\u00a9 ${new Date().getFullYear()} Coder Technologies, Inc.`, } export interface UserDropdownContentProps { user: TypesGen.User buildInfo?: TypesGen.BuildInfoResponse + supportLinks?: TypesGen.LinkConfig[] onPopoverClose: () => void onSignOut: () => void } @@ -32,14 +30,11 @@ export interface UserDropdownContentProps { export const UserDropdownContent: FC = ({ buildInfo, user, + supportLinks, onPopoverClose, onSignOut, }) => { const styles = useStyles() - const githubUrl = `https://github.com/coder/coder/issues/new?labels=needs+grooming&body=${encodeURIComponent(`Version: [\`${buildInfo?.version}\`](${buildInfo?.external_url}) - - `)}` - const discordUrl = `https://coder.com/chat?utm_source=coder&utm_medium=coder&utm_campaign=server-footer` return (
@@ -64,43 +59,33 @@ export const UserDropdownContent: FC = ({ - - - - {Language.docsLabel} - - - - - - - {Language.bugLabel} - - - - - - - {Language.discordLabel} - - - - + <> + {supportLinks && + supportLinks.map((link) => ( + + + {link.icon === "bug" && ( + + )} + {link.icon === "chat" && ( + + )} + {link.icon === "docs" && ( + + )} + {link.name} + + + ))} + + + {supportLinks && } ({ color: theme.palette.text.primary, }, })) + +const includeBuildInfo = ( + href: string, + buildInfo?: TypesGen.BuildInfoResponse, +): string => { + return href.replace( + "{CODER_BUILD_INFO}", + `${encodeURIComponent( + `Version: [\`${buildInfo?.version}\`](${buildInfo?.external_url})`, + )}`, + ) +} diff --git a/site/src/pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPage.tsx b/site/src/pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPage.tsx index a39ac1b2e37e0..c84751223f0db 100644 --- a/site/src/pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPage.tsx +++ b/site/src/pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPage.tsx @@ -1,4 +1,4 @@ -import { AppearanceConfig } from "api/typesGenerated" +import { UpdateAppearanceConfig } from "api/typesGenerated" import { useDashboard } from "components/Dashboard/DashboardProvider" import { FC } from "react" import { Helmet } from "react-helmet-async" @@ -15,7 +15,7 @@ const AppearanceSettingsPage: FC = () => { entitlements.features["appearance"].entitlement !== "not_entitled" const updateAppearance = ( - newConfig: Partial, + newConfig: Partial, preview: boolean, ) => { const newAppearance = { diff --git a/site/src/pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx b/site/src/pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx index 14b4bad0827a0..f64850d836c68 100644 --- a/site/src/pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx +++ b/site/src/pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx @@ -17,16 +17,16 @@ import { useTranslation } from "react-i18next" import makeStyles from "@material-ui/core/styles/makeStyles" import Switch from "@material-ui/core/Switch" import TextField from "@material-ui/core/TextField" -import { AppearanceConfig } from "api/typesGenerated" +import { UpdateAppearanceConfig } from "api/typesGenerated" import { Stack } from "components/Stack/Stack" import { useFormik } from "formik" import { useTheme } from "@material-ui/core/styles" export type AppearanceSettingsPageViewProps = { - appearance: AppearanceConfig + appearance: UpdateAppearanceConfig isEntitled: boolean updateAppearance: ( - newConfig: Partial, + newConfig: Partial, preview: boolean, ) => void } @@ -48,20 +48,22 @@ export const AppearanceSettingsPageView = ({ }) const logoFieldHelpers = getFormHelpers(logoForm) - const serviceBannerForm = useFormik({ - initialValues: { - message: appearance.service_banner.message, - enabled: appearance.service_banner.enabled, - background_color: appearance.service_banner.background_color, + const serviceBannerForm = useFormik( + { + initialValues: { + message: appearance.service_banner.message, + enabled: appearance.service_banner.enabled, + background_color: appearance.service_banner.background_color, + }, + onSubmit: (values) => + updateAppearance( + { + service_banner: values, + }, + false, + ), }, - onSubmit: (values) => - updateAppearance( - { - service_banner: values, - }, - false, - ), - }) + ) const serviceBannerFieldHelpers = getFormHelpers(serviceBannerForm) const [backgroundColor, setBackgroundColor] = useState( serviceBannerForm.values.background_color, diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 8876bd169a7a2..8881682f94d38 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -60,6 +60,25 @@ export const MockBuildInfo: TypesGen.BuildInfoResponse = { version: "v99.999.9999+c9cdf14", } +export const MockSupportLinks: TypesGen.LinkConfig[] = [ + { + name: "First link", + target: "http://first-link", + icon: "chat", + }, + { + name: "Second link", + target: "http://second-link", + icon: "docs", + }, + { + name: "Third link", + target: + "https://github.com/coder/coder/issues/new?labels=needs+grooming&body={CODER_BUILD_INFO}", + icon: "", + }, +] + export const MockUpdateCheck: TypesGen.UpdateCheckResponse = { current: true, url: "file:///mock-url",