feat: Enable custom support links (#6313)

* backend: support links

* frontend: Support links

* fmt

* test: CODER_SUPPORT_LINKS_0_NAME

* Go tests

* Use UpdateAppearanceConfig

* ui: UpdateAppearanceConfig

* fix: fmt

* Fix: site

* Fix: site tests

* fix: fmt

* fix

* test: check default support links
This commit is contained in:
Marcin Tojek
2023-02-27 09:25:04 +01:00
committed by GitHub
parent 16364db483
commit 2e53fb55da
20 changed files with 641 additions and 90 deletions
+15
View File
@@ -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:
+23
View File
@@ -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{
+82 -2
View File
@@ -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": {
+82 -2
View File
@@ -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": {
+20 -2
View File
@@ -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
+14 -7
View File
@@ -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).
+25
View File
@@ -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,
+151 -1
View File
@@ -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
+30 -4
View File
@@ -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
}
+66 -4
View File
@@ -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)
}
+27 -1
View File
@@ -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<boolean>
readonly address: DeploymentConfigField<string>
readonly experimental: DeploymentConfigField<boolean>
readonly support: SupportConfig
}
// From codersdk/deployment.go
@@ -434,6 +436,13 @@ export interface License {
readonly claims: Record<string, any>
}
// 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<string>
@@ -657,6 +666,11 @@ export interface ServiceBannerConfig {
readonly background_color?: string
}
// From codersdk/deployment.go
export interface SupportConfig {
readonly links: DeploymentConfigField<LinkConfig[]>
}
// From codersdk/deployment.go
export interface SwaggerConfig {
readonly enable: DeploymentConfigField<boolean>
@@ -818,6 +832,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
@@ -1331,4 +1351,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[]
+1
View File
@@ -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}
@@ -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<React.PropsWithChildren<NavbarViewProps>> = ({
user,
logo_url,
buildInfo,
supportLinks,
onSignOut,
canViewAuditLog,
canViewDeployment,
@@ -147,6 +149,7 @@ export const NavbarView: React.FC<React.PropsWithChildren<NavbarViewProps>> = ({
<UserDropdown
user={user}
buildInfo={buildInfo}
supportLinks={supportLinks}
onSignOut={onSignOut}
/>
)}
@@ -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<UserDropdownProps> = {}) => {
render(
<UserDropdown
user={props.user ?? MockUser}
supportLinks={MockSupportLinks}
onSignOut={props.onSignOut ?? jest.fn()}
/>,
)
@@ -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()
})
})
@@ -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<PropsWithChildren<UserDropdownProps>> = ({
buildInfo,
user,
supportLinks,
onSignOut,
}: UserDropdownProps) => {
const styles = useStyles()
@@ -69,6 +71,7 @@ export const UserDropdown: FC<PropsWithChildren<UserDropdownProps>> = ({
<UserDropdownContent
user={user}
buildInfo={buildInfo}
supportLinks={supportLinks}
onPopoverClose={onPopoverClose}
onSignOut={onSignOut}
/>
@@ -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", () => {
<UserDropdownContent
user={MockUser}
buildInfo={MockBuildInfo}
supportLinks={MockSupportLinks}
onSignOut={jest.fn()}
onPopoverClose={jest.fn()}
/>,
)
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()
})
@@ -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<UserDropdownContentProps> = ({
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})
<!--- Ask a question or leave feedback! -->`)}`
const discordUrl = `https://coder.com/chat?utm_source=coder&utm_medium=coder&utm_campaign=server-footer`
return (
<div>
@@ -64,43 +59,33 @@ export const UserDropdownContent: FC<UserDropdownContentProps> = ({
<Divider className={styles.divider} />
<a
href="https://coder.com/docs/coder-oss"
target="_blank"
rel="noreferrer"
className={styles.link}
>
<MenuItem className={styles.menuItem} onClick={onPopoverClose}>
<DocsIcon className={styles.menuItemIcon} />
<span className={styles.menuItemText}>{Language.docsLabel}</span>
</MenuItem>
</a>
<>
{supportLinks &&
supportLinks.map((link) => (
<a
href={includeBuildInfo(link.target, buildInfo)}
key={link.name}
target="_blank"
rel="noreferrer"
className={styles.link}
>
<MenuItem className={styles.menuItem} onClick={onPopoverClose}>
{link.icon === "bug" && (
<BugIcon className={styles.menuItemIcon} />
)}
{link.icon === "chat" && (
<ChatIcon className={styles.menuItemIcon} />
)}
{link.icon === "docs" && (
<DocsIcon className={styles.menuItemIcon} />
)}
<span className={styles.menuItemText}>{link.name}</span>
</MenuItem>
</a>
))}
</>
<a
href={githubUrl}
target="_blank"
rel="noreferrer"
className={styles.link}
>
<MenuItem className={styles.menuItem} onClick={onPopoverClose}>
<BugIcon className={styles.menuItemIcon} />
<span className={styles.menuItemText}>{Language.bugLabel}</span>
</MenuItem>
</a>
<a
href={discordUrl}
target="_blank"
rel="noreferrer"
className={styles.link}
>
<MenuItem className={styles.menuItem} onClick={onPopoverClose}>
<ChatIcon className={styles.menuItemIcon} />
<span className={styles.menuItemText}>{Language.discordLabel}</span>
</MenuItem>
</a>
<Divider className={styles.divider} />
{supportLinks && <Divider className={styles.divider} />}
<Stack className={styles.info} spacing={0}>
<a
@@ -181,3 +166,15 @@ const useStyles = makeStyles((theme) => ({
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})`,
)}`,
)
}
@@ -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<AppearanceConfig>,
newConfig: Partial<UpdateAppearanceConfig>,
preview: boolean,
) => {
const newAppearance = {
@@ -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<AppearanceConfig>,
newConfig: Partial<UpdateAppearanceConfig>,
preview: boolean,
) => void
}
@@ -48,20 +48,22 @@ export const AppearanceSettingsPageView = ({
})
const logoFieldHelpers = getFormHelpers(logoForm)
const serviceBannerForm = useFormik<AppearanceConfig["service_banner"]>({
initialValues: {
message: appearance.service_banner.message,
enabled: appearance.service_banner.enabled,
background_color: appearance.service_banner.background_color,
const serviceBannerForm = useFormik<UpdateAppearanceConfig["service_banner"]>(
{
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,
+19
View File
@@ -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",