diff --git a/coderd/coderd.go b/coderd/coderd.go index a04b36a631235..a595488687ca5 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -489,8 +489,9 @@ func New(options *Options) *API { type API struct { *Options - Auditor atomic.Pointer[audit.Auditor] - HTTPAuth *HTTPAuthorizer + Auditor atomic.Pointer[audit.Auditor] + WorkspaceClientCoordinateOverride atomic.Pointer[func(rw http.ResponseWriter) bool] + HTTPAuth *HTTPAuthorizer // APIHandler serves "/api/v2" APIHandler chi.Router diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 0a1c9a2694210..691564d600409 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -403,6 +403,11 @@ func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.R httpapi.ResourceNotFound(rw) return } + // This is used by Enterprise code to control the functionality of this route. + override := api.WorkspaceClientCoordinateOverride.Load() + if override != nil && (*override)(rw) { + return + } api.websocketWaitMutex.Lock() api.websocketWaitGroup.Add(1) diff --git a/codersdk/features.go b/codersdk/features.go index 3764470c8c0f5..89a3b7e9ed64f 100644 --- a/codersdk/features.go +++ b/codersdk/features.go @@ -15,12 +15,13 @@ const ( ) const ( - FeatureUserLimit = "user_limit" - FeatureAuditLog = "audit_log" - FeatureSCIM = "scim" + FeatureUserLimit = "user_limit" + FeatureAuditLog = "audit_log" + FeatureBrowserOnly = "browser_only" + FeatureSCIM = "scim" ) -var FeatureNames = []string{FeatureUserLimit, FeatureAuditLog, FeatureSCIM} +var FeatureNames = []string{FeatureUserLimit, FeatureAuditLog, FeatureBrowserOnly, FeatureSCIM} type Feature struct { Entitlement Entitlement `json:"entitlement"` diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index bf1f58eceafac..95832fc625e11 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -270,12 +270,14 @@ func (c *Client) DialWorkspaceAgentTailnet(ctx context.Context, logger slog.Logg } ctx, cancelFunc := context.WithCancel(ctx) closed := make(chan struct{}) + first := make(chan error) go func() { defer close(closed) + isFirst := true for retrier := retry.New(50*time.Millisecond, 10*time.Second); retrier.Wait(ctx); { logger.Debug(ctx, "connecting") // nolint:bodyclose - ws, _, err := websocket.Dial(ctx, coordinateURL.String(), &websocket.DialOptions{ + ws, res, err := websocket.Dial(ctx, coordinateURL.String(), &websocket.DialOptions{ HTTPClient: httpClient, // Need to disable compression to avoid a data-race. CompressionMode: websocket.CompressionDisabled, @@ -283,10 +285,22 @@ func (c *Client) DialWorkspaceAgentTailnet(ctx context.Context, logger slog.Logg if errors.Is(err, context.Canceled) { return } + if isFirst { + if res.StatusCode == http.StatusConflict { + first <- readBodyAsError(res) + return + } + isFirst = false + close(first) + } if err != nil { logger.Debug(ctx, "failed to dial", slog.Error(err)) continue } + if isFirst { + isFirst = false + close(first) + } sendNode, errChan := tailnet.ServeCoordinator(websocket.NetConn(ctx, ws, websocket.MessageBinary), func(node []*tailnet.Node) error { return conn.UpdateNodes(node) }) @@ -305,13 +319,19 @@ func (c *Client) DialWorkspaceAgentTailnet(ctx context.Context, logger slog.Logg _ = ws.Close(websocket.StatusAbnormalClosure, "") } }() + err = <-first + if err != nil { + cancelFunc() + _ = conn.Close() + return nil, err + } return &agent.Conn{ Conn: conn, CloseFunc: func() { cancelFunc() <-closed }, - }, nil + }, err } // WorkspaceAgent returns an agent by ID. diff --git a/docs/admin/enterprise.md b/docs/admin/enterprise.md index 881a6e8b6f8aa..6fbe2109d00ce 100644 --- a/docs/admin/enterprise.md +++ b/docs/admin/enterprise.md @@ -6,6 +6,7 @@ Contact sales@coder.com to obtain a license. These features are: * Audit Logging + * Browser Only Connections ## Adding your license key diff --git a/enterprise/cli/features_test.go b/enterprise/cli/features_test.go index 7f7d13a5180d6..4832082e28c32 100644 --- a/enterprise/cli/features_test.go +++ b/enterprise/cli/features_test.go @@ -57,12 +57,14 @@ func TestFeaturesList(t *testing.T) { var entitlements codersdk.Entitlements err := json.Unmarshal(buf.Bytes(), &entitlements) require.NoError(t, err, "unmarshal JSON output") - assert.Len(t, entitlements.Features, 2) + assert.Len(t, entitlements.Features, 3) assert.Empty(t, entitlements.Warnings) assert.Equal(t, codersdk.EntitlementNotEntitled, entitlements.Features[codersdk.FeatureUserLimit].Entitlement) assert.Equal(t, codersdk.EntitlementNotEntitled, entitlements.Features[codersdk.FeatureAuditLog].Entitlement) + assert.Equal(t, codersdk.EntitlementNotEntitled, + entitlements.Features[codersdk.FeatureBrowserOnly].Entitlement) assert.False(t, entitlements.HasLicense) }) } diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index b41197f32614f..f9961eba0cb5d 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/cobra" "github.com/coder/coder/cli/cliflag" + "github.com/coder/coder/cli/cliui" "github.com/coder/coder/enterprise/coderd" agpl "github.com/coder/coder/cli" @@ -15,11 +16,13 @@ import ( func server() *cobra.Command { var ( auditLogging bool + browserOnly bool scimAuthHeader string ) cmd := agpl.Server(func(ctx context.Context, options *agplcoderd.Options) (*agplcoderd.API, error) { api, err := coderd.New(ctx, &coderd.Options{ AuditLogging: auditLogging, + BrowserOnly: browserOnly, SCIMAPIKey: []byte(scimAuthHeader), Options: options, }) @@ -28,9 +31,14 @@ func server() *cobra.Command { } return api.AGPL, nil }) + enterpriseOnly := cliui.Styles.Keyword.Render("This is an Enterprise feature. Contact sales@coder.com for licensing") + cliflag.BoolVarP(cmd.Flags(), &auditLogging, "audit-logging", "", "CODER_AUDIT_LOGGING", true, - "Specifies whether audit logging is enabled.") - cliflag.StringVarP(cmd.Flags(), &scimAuthHeader, "scim-auth-header", "", "CODER_SCIM_API_KEY", "", "Enables SCIM and sets the authentication header for the built-in SCIM server. New users are automatically created with OIDC authentication.") + "Specifies whether audit logging is enabled. "+enterpriseOnly) + cliflag.BoolVarP(cmd.Flags(), &browserOnly, "browser-only", "", "CODER_BROWSER_ONLY", false, + "Whether Coder only allows connections to workspaces via the browser. "+enterpriseOnly) + cliflag.StringVarP(cmd.Flags(), &scimAuthHeader, "scim-auth-header", "", "CODER_SCIM_API_KEY", "", + "Enables SCIM and sets the authentication header for the built-in SCIM server. New users are automatically created with OIDC authentication. "+enterpriseOnly) return cmd } diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index bc8b2088a85a6..237f15ebdcda5 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -52,7 +52,6 @@ func New(ctx context.Context, options *Options) (*API, error) { OIDC: options.OIDCConfig, } apiKeyMiddleware := httpmw.ExtractAPIKey(options.Database, oauthConfigs, false) - api.AGPL.APIHandler.Group(func(r chi.Router) { r.Get("/entitlements", api.serveEntitlements) r.Route("/licenses", func(r chi.Router) { @@ -88,7 +87,9 @@ func New(ctx context.Context, options *Options) (*API, error) { type Options struct { *coderd.Options - AuditLogging bool + AuditLogging bool + // Whether to block non-browser connections. + BrowserOnly bool SCIMAPIKey []byte EntitlementsUpdateInterval time.Duration Keys map[string]ed25519.PublicKey @@ -107,6 +108,7 @@ type entitlements struct { hasLicense bool activeUsers codersdk.Feature auditLogs codersdk.Entitlement + browserOnly codersdk.Entitlement scim codersdk.Entitlement } @@ -131,8 +133,9 @@ func (api *API) updateEntitlements(ctx context.Context) error { Enabled: false, Entitlement: codersdk.EntitlementNotEntitled, }, - auditLogs: codersdk.EntitlementNotEntitled, - scim: codersdk.EntitlementNotEntitled, + auditLogs: codersdk.EntitlementNotEntitled, + scim: codersdk.EntitlementNotEntitled, + browserOnly: codersdk.EntitlementNotEntitled, } // Here we loop through licenses to detect enabled features. @@ -165,6 +168,9 @@ func (api *API) updateEntitlements(ctx context.Context) error { if claims.Features.AuditLog > 0 { entitlements.auditLogs = entitlement } + if claims.Features.BrowserOnly > 0 { + entitlements.browserOnly = entitlement + } if claims.Features.SCIM > 0 { entitlements.scim = entitlement } @@ -174,7 +180,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { auditor := agplaudit.NewNop() // A flag could be added to the options that would allow disabling // enhanced audit logging here! - if entitlements.auditLogs == codersdk.EntitlementEntitled && api.AuditLogging { + if entitlements.auditLogs != codersdk.EntitlementNotEntitled && api.AuditLogging { auditor = audit.NewAuditor( audit.DefaultFilter, backends.NewPostgres(api.Database, true), @@ -184,6 +190,14 @@ func (api *API) updateEntitlements(ctx context.Context) error { api.AGPL.Auditor.Store(&auditor) } + if entitlements.browserOnly != api.entitlements.browserOnly { + var handler func(rw http.ResponseWriter) bool + if entitlements.browserOnly != codersdk.EntitlementNotEntitled && api.BrowserOnly { + handler = api.shouldBlockNonBrowserConnections + } + api.AGPL.WorkspaceClientCoordinateOverride.Store(&handler) + } + api.entitlements = entitlements return nil @@ -230,6 +244,15 @@ func (api *API) serveEntitlements(rw http.ResponseWriter, r *http.Request) { "Audit logging is enabled but your license for this feature is expired.") } + resp.Features[codersdk.FeatureBrowserOnly] = codersdk.Feature{ + Entitlement: entitlements.browserOnly, + Enabled: api.BrowserOnly, + } + if entitlements.browserOnly == codersdk.EntitlementGracePeriod && api.BrowserOnly { + resp.Warnings = append(resp.Warnings, + "Browser only connections are enabled but your license for this feature is expired.") + } + httpapi.Write(ctx, rw, http.StatusOK, resp) } diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go index aedd79417be41..4f5d47ec56a23 100644 --- a/enterprise/coderd/coderd_test.go +++ b/enterprise/coderd/coderd_test.go @@ -84,15 +84,18 @@ func TestEntitlements(t *testing.T) { }) t.Run("Warnings", func(t *testing.T) { t.Parallel() - client := coderdenttest.New(t, nil) + client := coderdenttest.New(t, &coderdenttest.Options{ + BrowserOnly: true, + }) first := coderdtest.CreateFirstUser(t, client) for i := 0; i < 4; i++ { coderdtest.CreateAnotherUser(t, client, first.OrganizationID) } coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - UserLimit: 4, - AuditLog: true, - GraceAt: time.Now().Add(-time.Second), + UserLimit: 4, + AuditLog: true, + BrowserOnly: true, + GraceAt: time.Now().Add(-time.Second), }) res, err := client.Entitlements(context.Background()) require.NoError(t, err) @@ -107,11 +110,18 @@ func TestEntitlements(t *testing.T) { assert.True(t, al.Enabled) assert.Nil(t, al.Limit) assert.Nil(t, al.Actual) - assert.Len(t, res.Warnings, 2) + bo := res.Features[codersdk.FeatureBrowserOnly] + assert.Equal(t, codersdk.EntitlementGracePeriod, bo.Entitlement) + assert.True(t, bo.Enabled) + assert.Nil(t, bo.Limit) + assert.Nil(t, bo.Actual) + assert.Len(t, res.Warnings, 3) assert.Contains(t, res.Warnings, "Your deployment has 5 active users but is only licensed for 4.") assert.Contains(t, res.Warnings, "Audit logging is enabled but your license for this feature is expired.") + assert.Contains(t, res.Warnings, + "Browser only connections are enabled but your license for this feature is expired.") }) t.Run("Pubsub", func(t *testing.T) { t.Parallel() diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index 30dcbfea39eb2..bef7fdb313f6f 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -36,6 +36,7 @@ func init() { type Options struct { *coderdtest.Options + BrowserOnly bool EntitlementsUpdateInterval time.Duration SCIMAPIKey []byte } @@ -56,6 +57,7 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c srv, cancelFunc, oop := coderdtest.NewOptions(t, options.Options) coderAPI, err := coderd.New(context.Background(), &coderd.Options{ AuditLogging: true, + BrowserOnly: options.BrowserOnly, SCIMAPIKey: options.SCIMAPIKey, Options: oop, EntitlementsUpdateInterval: options.EntitlementsUpdateInterval, @@ -84,6 +86,7 @@ type LicenseOptions struct { ExpiresAt time.Time UserLimit int64 AuditLog bool + BrowserOnly bool SCIM bool } @@ -108,6 +111,10 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string { if options.AuditLog { auditLog = 1 } + browserOnly := int64(0) + if options.BrowserOnly { + browserOnly = 1 + } scim := int64(0) if options.SCIM { scim = 1 @@ -125,9 +132,10 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string { AccountID: options.AccountID, Version: coderd.CurrentVersion, Features: coderd.Features{ - UserLimit: options.UserLimit, - AuditLog: auditLog, - SCIM: scim, + UserLimit: options.UserLimit, + AuditLog: auditLog, + BrowserOnly: browserOnly, + SCIM: scim, }, } tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c) diff --git a/enterprise/coderd/licenses.go b/enterprise/coderd/licenses.go index cf26089872291..a97e8ebcca4fe 100644 --- a/enterprise/coderd/licenses.go +++ b/enterprise/coderd/licenses.go @@ -45,9 +45,10 @@ var key20220812 []byte var Keys = map[string]ed25519.PublicKey{"2022-08-12": ed25519.PublicKey(key20220812)} type Features struct { - UserLimit int64 `json:"user_limit"` - AuditLog int64 `json:"audit_log"` - SCIM int64 `json:"scim"` + UserLimit int64 `json:"user_limit"` + AuditLog int64 `json:"audit_log"` + BrowserOnly int64 `json:"browser_only"` + SCIM int64 `json:"scim"` } type Claims struct { diff --git a/enterprise/coderd/licenses_test.go b/enterprise/coderd/licenses_test.go index 12fa82583436e..0b74c1caf6239 100644 --- a/enterprise/coderd/licenses_test.go +++ b/enterprise/coderd/licenses_test.go @@ -78,16 +78,18 @@ func TestGetLicense(t *testing.T) { defer cancel() coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - AccountID: "testing", - AuditLog: true, - SCIM: true, + AccountID: "testing", + AuditLog: true, + SCIM: true, + BrowserOnly: true, }) coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - AccountID: "testing2", - AuditLog: true, - SCIM: true, - UserLimit: 200, + AccountID: "testing2", + AuditLog: true, + SCIM: true, + BrowserOnly: true, + UserLimit: 200, }) licenses, err := client.Licenses(ctx) @@ -96,16 +98,18 @@ func TestGetLicense(t *testing.T) { assert.Equal(t, int32(1), licenses[0].ID) assert.Equal(t, "testing", licenses[0].Claims["account_id"]) assert.Equal(t, map[string]interface{}{ - codersdk.FeatureUserLimit: json.Number("0"), - codersdk.FeatureAuditLog: json.Number("1"), - codersdk.FeatureSCIM: json.Number("1"), + codersdk.FeatureUserLimit: json.Number("0"), + codersdk.FeatureAuditLog: json.Number("1"), + codersdk.FeatureSCIM: json.Number("1"), + codersdk.FeatureBrowserOnly: json.Number("1"), }, licenses[0].Claims["features"]) assert.Equal(t, int32(2), licenses[1].ID) assert.Equal(t, "testing2", licenses[1].Claims["account_id"]) assert.Equal(t, map[string]interface{}{ - codersdk.FeatureUserLimit: json.Number("200"), - codersdk.FeatureAuditLog: json.Number("1"), - codersdk.FeatureSCIM: json.Number("1"), + codersdk.FeatureUserLimit: json.Number("200"), + codersdk.FeatureAuditLog: json.Number("1"), + codersdk.FeatureSCIM: json.Number("1"), + codersdk.FeatureBrowserOnly: json.Number("1"), }, licenses[1].Claims["features"]) }) } diff --git a/enterprise/coderd/workspaceagents.go b/enterprise/coderd/workspaceagents.go new file mode 100644 index 0000000000000..def912a90f893 --- /dev/null +++ b/enterprise/coderd/workspaceagents.go @@ -0,0 +1,22 @@ +package coderd + +import ( + "context" + "net/http" + + "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/codersdk" +) + +func (api *API) shouldBlockNonBrowserConnections(rw http.ResponseWriter) bool { + api.entitlementsMu.Lock() + browserOnly := api.entitlements.browserOnly + api.entitlementsMu.Unlock() + if api.BrowserOnly && browserOnly != codersdk.EntitlementNotEntitled { + httpapi.Write(context.Background(), rw, http.StatusConflict, codersdk.Response{ + Message: "Non-browser connections are disabled for your deployment.", + }) + return true + } + return false +} diff --git a/enterprise/coderd/workspaceagents_test.go b/enterprise/coderd/workspaceagents_test.go new file mode 100644 index 0000000000000..5b7c5b93b601d --- /dev/null +++ b/enterprise/coderd/workspaceagents_test.go @@ -0,0 +1,97 @@ +package coderd_test + +import ( + "context" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/agent" + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/enterprise/coderd/coderdenttest" + "github.com/coder/coder/provisioner/echo" + "github.com/coder/coder/provisionersdk/proto" +) + +func TestBlockNonBrowser(t *testing.T) { + t.Parallel() + t.Run("Enabled", func(t *testing.T) { + t.Parallel() + client := coderdenttest.New(t, &coderdenttest.Options{ + BrowserOnly: true, + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + }) + user := coderdtest.CreateFirstUser(t, client) + coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + BrowserOnly: true, + }) + id := setupWorkspaceAgent(t, client, user) + _, err := client.DialWorkspaceAgentTailnet(context.Background(), slog.Logger{}, id) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusConflict, apiErr.StatusCode()) + }) + t.Run("Disabled", func(t *testing.T) { + t.Parallel() + client := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + }) + user := coderdtest.CreateFirstUser(t, client) + coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + BrowserOnly: false, + }) + id := setupWorkspaceAgent(t, client, user) + conn, err := client.DialWorkspaceAgentTailnet(context.Background(), slog.Logger{}, id) + require.NoError(t, err) + _ = conn.Close() + }) +} + +func setupWorkspaceAgent(t *testing.T, client *codersdk.Client, user codersdk.CreateFirstUserResponse) uuid.UUID { + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Name: "example", + Auth: &proto.Agent_Token{ + Token: authToken, + }, + }}, + }}, + }, + }, + }}, + }) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + agentClient := codersdk.New(client.URL) + agentClient.SessionToken = authToken + agentCloser := agent.New(agent.Options{ + FetchMetadata: agentClient.WorkspaceAgentMetadata, + CoordinatorDialer: agentClient.ListenWorkspaceAgentTailnet, + Logger: slogtest.Make(t, nil).Named("agent"), + }) + defer func() { + _ = agentCloser.Close() + }() + resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) + return resources[0].Agents[0].ID +} diff --git a/site/src/api/types.ts b/site/src/api/types.ts index 1ac58f28cfccc..63d01a0ab55dd 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -19,4 +19,5 @@ export type Message = { message: string } export enum FeatureNames { AuditLog = "audit_log", UserLimit = "user_limit", + BrowserOnly = "browser_only", } diff --git a/site/src/components/Resources/Resources.tsx b/site/src/components/Resources/Resources.tsx index 4f90a3a622c6a..d935f2096d1bc 100644 --- a/site/src/components/Resources/Resources.tsx +++ b/site/src/components/Resources/Resources.tsx @@ -41,6 +41,7 @@ interface ResourcesProps { workspace: Workspace canUpdateWorkspace: boolean buildInfo?: BuildInfoResponse | undefined + hideSSHButton?: boolean } export const Resources: FC> = ({ @@ -49,6 +50,7 @@ export const Resources: FC> = ({ workspace, canUpdateWorkspace, buildInfo, + hideSSHButton, }) => { const styles = useStyles() const theme: Theme = useTheme() @@ -149,7 +151,12 @@ export const Resources: FC> = ({
{canUpdateWorkspace && agent.status === "connected" && ( <> - + {!hideSSHButton && ( + + )} > buildInfo?: TypesGen.BuildInfoResponse } @@ -63,6 +64,7 @@ export const Workspace: FC> = ({ builds, canUpdateWorkspace, workspaceErrors, + hideSSHButton, buildInfo, }) => { const styles = useStyles() @@ -137,6 +139,7 @@ export const Workspace: FC> = ({ workspace={workspace} canUpdateWorkspace={canUpdateWorkspace} buildInfo={buildInfo} + hideSSHButton={hideSSHButton} /> )} diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index 39192b6224eb2..6b243aaa500a2 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -1,11 +1,13 @@ import { makeStyles } from "@material-ui/core/styles" import { useActor, useMachine, useSelector } from "@xstate/react" +import { FeatureNames } from "api/types" import dayjs from "dayjs" import minMax from "dayjs/plugin/minMax" import { FC, useContext, useEffect } from "react" import { Helmet } from "react-helmet-async" import { useTranslation } from "react-i18next" import { useParams } from "react-router-dom" +import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors" import { DeleteDialog } from "../../components/Dialogs/DeleteDialog/DeleteDialog" import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary" import { FullScreenLoader } from "../../components/Loader/FullScreenLoader" @@ -30,6 +32,7 @@ export const WorkspacePage: FC = () => { const xServices = useContext(XServiceContext) const me = useSelector(xServices.authXService, selectUser) + const featureVisibility = useSelector(xServices.entitlementsXService, selectFeatureVisibility) const [workspaceState, workspaceSend] = useMachine(workspaceMachine, { context: { @@ -130,6 +133,7 @@ export const WorkspacePage: FC = () => { resources={workspace.latest_build.resources} builds={builds} canUpdateWorkspace={canUpdateWorkspace} + hideSSHButton={featureVisibility[FeatureNames.BrowserOnly]} workspaceErrors={{ [WorkspaceErrors.GET_RESOURCES_ERROR]: refreshWorkspaceWarning, [WorkspaceErrors.GET_BUILDS_ERROR]: getBuildsError, diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 04183a3ddfbd7..34b06a219656a 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -768,6 +768,10 @@ export const MockEntitlementsWithWarnings: TypesGen.Entitlements = { enabled: true, entitlement: "entitled", }, + browser_only: { + enabled: true, + entitlement: "entitled", + }, }, }