diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 8b82c6d1795..6a906f903f2 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8872,6 +8872,13 @@ const docTemplate = `{ "name": "templateversion", "in": "path", "required": true + }, + { + "type": "string", + "format": "uuid", + "description": "Owner to report external auth state for. Defaults to the requesting user.", + "name": "user_id", + "in": "query" } ], "responses": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index eefbd52c117..283dcea72ed 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7876,6 +7876,13 @@ "name": "templateversion", "in": "path", "required": true + }, + { + "type": "string", + "format": "uuid", + "description": "Owner to report external auth state for. Defaults to the requesting user.", + "name": "user_id", + "in": "query" } ], "responses": { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 879e53e799f..b35529b0286 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -837,6 +837,28 @@ var ( }), Scope: rbac.ScopeAll, }.WithCachedASTValue() + + // subjectExternalAuthCoordinator is used to check whether a user has configured + // external auth providers or not when an admin is creating a workspace for + // another user. + subjectExternalAuthCoordinator = rbac.Subject{ + Type: rbac.SubjectTypeExternalAuthCoordinator, + FriendlyName: "External Auth Coordinator", + ID: uuid.Nil.String(), + Roles: rbac.Roles([]rbac.Role{ + { + Identifier: rbac.RoleIdentifier{Name: "external-auth-coordinator"}, + DisplayName: "External Auth Coordinator", + Site: rbac.Permissions(map[string][]policy.Action{ + // policy.ActionUpdatePersonal allows us to refresh tokens. + rbac.ResourceUser.Type: {policy.ActionReadPersonal, policy.ActionUpdatePersonal}, + }), + User: []rbac.Permission{}, + ByOrgID: map[string]rbac.OrgPermissions{}, + }, + }), + Scope: rbac.ScopeAll, + }.WithCachedASTValue() ) // AsProvisionerd returns a context with an actor that has permissions required @@ -985,6 +1007,12 @@ func AsSCIMProvisioner(ctx context.Context) context.Context { return As(ctx, subjectSCIM) } +// AsExternalAuthCoordinator returns a context with an actor that has permission to +// read and refresh any user's external auth links. +func AsExternalAuthCoordinator(ctx context.Context) context.Context { + return As(ctx, subjectExternalAuthCoordinator) +} + var AsRemoveActor = rbac.Subject{ ID: "remove-actor", } diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 261170057a2..d06e4e5c5ee 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -7633,3 +7633,48 @@ func TestAsChatd(t *testing.T) { require.Error(t, err, "provisioner daemon read should be denied") }) } + +func TestAsExternalAuthChecker(t *testing.T) { + t.Parallel() + + ctx := dbauthz.AsExternalAuthCoordinator(context.Background()) + actor, ok := dbauthz.ActorFromContext(ctx) + require.True(t, ok, "actor must be present") + + auth := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry()) + + t.Run("AllowedActions", func(t *testing.T) { + t.Parallel() + + // Reading and refreshing a user's external auth link requires personal + // read and update on the user resource. + for _, action := range []policy.Action{ + policy.ActionReadPersonal, policy.ActionUpdatePersonal, + } { + err := auth.Authorize(ctx, actor, action, rbac.ResourceUser) + require.NoError(t, err, "user %s should be allowed", action) + } + }) + + t.Run("DeniedActions", func(t *testing.T) { + t.Parallel() + + // No general user read/write, only personal external auth access. + for _, action := range []policy.Action{ + policy.ActionRead, policy.ActionCreate, + policy.ActionUpdate, policy.ActionDelete, + } { + err := auth.Authorize(ctx, actor, action, rbac.ResourceUser) + require.Error(t, err, "user %s should be denied", action) + } + + // Unlike AsSystemRestricted, this actor cannot read other resources. + for _, res := range []rbac.Object{ + rbac.ResourceWorkspace, rbac.ResourceTemplate, + rbac.ResourceApiKey, rbac.ResourceOrganization, + } { + err := auth.Authorize(ctx, actor, policy.ActionRead, res) + require.Error(t, err, "%s read should be denied", res.Type) + } + }) +} diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go index 58086159d9d..c1b94300a90 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -88,6 +88,7 @@ const ( SubjectTypeChatd SubjectType = "chatd" SubjectTypeAIProviderMetadataReader SubjectType = "ai_provider_metadata_reader" SubjectTypeSCIMProvisioner SubjectType = "scim_provisioner" + SubjectTypeExternalAuthCoordinator SubjectType = "external_auth_coordinator" ) const ( diff --git a/coderd/templateversions.go b/coderd/templateversions.go index 682a7bb0b1b..d680260e599 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -329,6 +329,7 @@ func (api *API) templateVersionRichParameters(rw http.ResponseWriter, r *http.Re // @Produce json // @Tags Templates // @Param templateversion path string true "Template version ID" format(uuid) +// @Param user_id query string false "Owner to report external auth state for. Defaults to the requesting user." format(uuid) // @Success 200 {array} codersdk.TemplateVersionExternalAuth // @Router /api/v2/templateversions/{templateversion}/external-auth [get] func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Request) { @@ -338,7 +339,33 @@ func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Requ templateVersion = httpmw.TemplateVersionParam(r) ) - providers, err := api.templateVersionExternalAuthForUser(ctx, templateVersion, apiKey.UserID) + ownerID := apiKey.UserID + externalAuthCtx := ctx + if q := r.URL.Query().Get("user_id"); q != "" && q != codersdk.Me { + id, err := uuid.Parse(q) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid user_id query parameter.", + Detail: err.Error(), + }) + return + } + ownerID = id + + // Verify that the user has permission to create a workspace on behalf of + // the proposed workspace owner. If so, use a system actor to perform later + // checks that the user is unlikely to have the other required permissions + // for. + if !api.Authorize(r, policy.ActionCreate, + rbac.ResourceWorkspace.InOrg(templateVersion.OrganizationID).WithOwner(ownerID.String())) { + httpapi.Forbidden(rw) + return + } + //nolint:gocritic // Authorized as create-workspace-for-owner above; the checker only reads/refreshes the owner's external auth links. + externalAuthCtx = dbauthz.AsExternalAuthCoordinator(ctx) + } + + providers, err := api.templateVersionExternalAuthForUser(externalAuthCtx, templateVersion, ownerID) if err != nil { httperror.WriteResponseError(ctx, rw, err) return diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index d057787655c..ec631d36d1a 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -1047,6 +1047,105 @@ func TestTemplateVersionsExternalAuth(t *testing.T) { require.True(t, providers[0].Authenticated) require.True(t, providers[0].Optional) }) + t.Run("ForAnotherUser", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + ExternalAuthConfigs: []*externalauth.Config{{ + InstrumentedOAuth2Config: &testutil.OAuth2Config{}, + ID: "github", + Regex: regexp.MustCompile(`github\.com`), + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), + DisplayName: "GitHub", + RefreshGroup: new(singleflight.Group), + }}, + }) + owner := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionGraph: []*proto.Response{{ + Type: &proto.Response_Graph{ + Graph: &proto.GraphComplete{ + ExternalAuthProviders: []*proto.ExternalAuthProviderResource{{Id: "github"}}, + }, + }, + }}, + }) + version = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + require.Empty(t, version.Job.Error) + // Publish a template so the org admin can read the version. + _ = coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // The requester is an org admin who can create workspaces for other users + // but does not have personal read access to them. The target user + // authenticates with the provider. + adminClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, + rbac.ScopedRoleOrgAdmin(owner.OrganizationID)) + memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + resp := coderdtest.RequestExternalAuthCallback(t, "github", memberClient) + _ = resp.Body.Close() + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + + // The requesting admin has not authenticated, so their own state is + // unauthenticated. + self, err := adminClient.TemplateVersionExternalAuth(ctx, version.ID) + require.NoError(t, err) + require.Len(t, self, 1) + require.False(t, self[0].Authenticated) + + // The reported state is the target user's, not the requesting admin's: + // the admin is unauthenticated but the target shows authenticated. + forOwner, err := adminClient.TemplateVersionExternalAuth(ctx, version.ID, + codersdk.WithQueryParam("user_id", member.ID.String())) + require.NoError(t, err) + require.Len(t, forOwner, 1) + require.True(t, forOwner[0].Authenticated) + }) + t.Run("ForAnotherUserUnauthorized", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + ExternalAuthConfigs: []*externalauth.Config{{ + InstrumentedOAuth2Config: &testutil.OAuth2Config{}, + ID: "github", + Regex: regexp.MustCompile(`github\.com`), + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), + DisplayName: "GitHub", + RefreshGroup: new(singleflight.Group), + }}, + }) + owner := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionGraph: []*proto.Response{{ + Type: &proto.Response_Graph{ + Graph: &proto.GraphComplete{ + ExternalAuthProviders: []*proto.ExternalAuthProviderResource{{Id: "github"}}, + }, + }, + }}, + }) + version = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + require.Empty(t, version.Job.Error) + // Publish a template so org members can read the version. + _ = coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + requesterClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + _, target := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // A plain member cannot view another user's external auth state, because + // they cannot create a workspace on that user's behalf. + _, err := requesterClient.TemplateVersionExternalAuth(ctx, version.ID, + codersdk.WithQueryParam("user_id", target.ID.String())) + require.Error(t, err) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) + }) } func TestTemplateVersionResources(t *testing.T) { diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 5531e195a5f..eb89dc723cf 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -873,8 +873,8 @@ func createWorkspace( // at build time uses the owner's external auth links, so the owner is the // subject of the check even when another user initiates the build. func (api *API) requireWorkspaceOwnerExternalAuth(ctx context.Context, templateVersion database.TemplateVersion, ownerID uuid.UUID) error { - //nolint:gocritic // System access is required to validate the workspace owner's external auth links because admins and API clients may create workspaces for other users. - providers, err := api.templateVersionExternalAuthForUser(dbauthz.AsSystemRestricted(ctx), templateVersion, ownerID) + //nolint:gocritic // Reads/refreshes the external auth links. Necessary when admins create workspaces for other users. + providers, err := api.templateVersionExternalAuthForUser(dbauthz.AsExternalAuthCoordinator(ctx), templateVersion, ownerID) if err != nil { return err } diff --git a/codersdk/templateversions.go b/codersdk/templateversions.go index 01cd2337074..88cf544ec3c 100644 --- a/codersdk/templateversions.go +++ b/codersdk/templateversions.go @@ -144,8 +144,8 @@ func (c *Client) TemplateVersionRichParameters(ctx context.Context, version uuid } // TemplateVersionExternalAuth returns authentication providers for the requested template version. -func (c *Client) TemplateVersionExternalAuth(ctx context.Context, version uuid.UUID) ([]TemplateVersionExternalAuth, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templateversions/%s/external-auth", version), nil) +func (c *Client) TemplateVersionExternalAuth(ctx context.Context, version uuid.UUID, opts ...RequestOption) ([]TemplateVersionExternalAuth, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templateversions/%s/external-auth", version), nil, opts...) if err != nil { return nil, err } diff --git a/docs/reference/api/templates.md b/docs/reference/api/templates.md index 3b3443d23c5..ade3daf8823 100644 --- a/docs/reference/api/templates.md +++ b/docs/reference/api/templates.md @@ -2839,9 +2839,10 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion}/e ### Parameters -| Name | In | Type | Required | Description | -|-------------------|------|--------------|----------|---------------------| -| `templateversion` | path | string(uuid) | true | Template version ID | +| Name | In | Type | Required | Description | +|-------------------|-------|--------------|----------|---------------------------------------------------------------------------| +| `templateversion` | path | string(uuid) | true | Template version ID | +| `user_id` | query | string(uuid) | false | Owner to report external auth state for. Defaults to the requesting user. | ### Example responses diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 0930ba3bdd2..7cb182a144a 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1160,9 +1160,10 @@ class ApiMethods { getTemplateVersionExternalAuth = async ( versionId: string, + userId = "me", ): Promise => { const response = await this.axios.get( - `/api/v2/templateversions/${versionId}/external-auth`, + `/api/v2/templateversions/${versionId}/external-auth?user_id=${userId}`, ); return response.data; diff --git a/site/src/api/queries/templates.ts b/site/src/api/queries/templates.ts index 9d1f6740f80..2a01eff1a45 100644 --- a/site/src/api/queries/templates.ts +++ b/site/src/api/queries/templates.ts @@ -206,16 +206,20 @@ export const templaceACLAvailable = ( }; }; -const templateVersionExternalAuthKey = (versionId: string) => [ +const templateVersionExternalAuthKey = (versionId: string, userId = "me") => [ templateVersionRoot, versionId, + userId, "externalAuth", ]; -export const templateVersionExternalAuth = (versionId: string) => { +export const templateVersionExternalAuth = ( + versionId: string, + userId = "me", +) => { return { - queryKey: templateVersionExternalAuthKey(versionId), - queryFn: () => API.getTemplateVersionExternalAuth(versionId), + queryKey: templateVersionExternalAuthKey(versionId, userId), + queryFn: () => API.getTemplateVersionExternalAuth(versionId, userId), }; }; diff --git a/site/src/hooks/useExternalAuth.ts b/site/src/hooks/useExternalAuth.ts index 81dcae5de06..3604db9a902 100644 --- a/site/src/hooks/useExternalAuth.ts +++ b/site/src/hooks/useExternalAuth.ts @@ -4,7 +4,10 @@ import { templateVersionExternalAuth } from "#/api/queries/templates"; export type ExternalAuthPollingState = "idle" | "polling" | "abandoned"; -export const useExternalAuth = (versionId: string | undefined) => { +export const useExternalAuth = ( + versionId: string | undefined, + userId: string, +) => { const [pollingState, setPollingState] = useState< Record >({}); @@ -20,7 +23,7 @@ export const useExternalAuth = (versionId: string | undefined) => { isPending: isLoadingExternalAuth, error, } = useQuery({ - ...templateVersionExternalAuth(versionId ?? ""), + ...templateVersionExternalAuth(versionId ?? "", userId), enabled: Boolean(versionId), refetchInterval: isAnyPolling ? 1000 : false, }); diff --git a/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx b/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx index 21481e6c2d4..e6ead5046be 100644 --- a/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx +++ b/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx @@ -179,7 +179,7 @@ const CreateTaskForm: FC = ({ templates, onSuccess }) => { externalAuthError, isPollingExternalAuth, isLoadingExternalAuth, - } = useExternalAuth(selectedVersionId); + } = useExternalAuth(selectedVersionId, "me"); const missedExternalAuth = externalAuth?.filter( (auth) => !auth.optional && !auth.authenticated, ); @@ -437,7 +437,7 @@ const ExternalAuthButtons: FC = ({ missedExternalAuth, }) => { const { startPollingExternalAuth, externalAuthPollingState } = - useExternalAuth(versionId); + useExternalAuth(versionId, "me"); return missedExternalAuth.map((auth) => { const isPollingExternalAuth = diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index e3df74b64ff..3636a262ced 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -254,7 +254,7 @@ const CreateWorkspacePage: FC = () => { externalAuthPollingState, startPollingExternalAuth, isLoadingExternalAuth, - } = useExternalAuth(realizedVersionId); + } = useExternalAuth(realizedVersionId, owner.id); const isLoadingFormData = ws.current?.readyState === WebSocket.CONNECTING || diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx index 4a2a68a1d49..fb4b889ce7d 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx @@ -3,7 +3,11 @@ import { expect, screen, within } from "storybook/test"; import { DetailedError } from "#/api/errors"; import type { Preset, PreviewParameter } from "#/api/typesGenerated"; import { chromatic } from "#/testHelpers/chromatic"; -import { MockTemplate, MockUserOwner } from "#/testHelpers/entities"; +import { + MockTemplate, + MockUserMember, + MockUserOwner, +} from "#/testHelpers/entities"; import { CreateWorkspacePageView } from "./CreateWorkspacePageView"; const meta: Meta = { @@ -15,6 +19,8 @@ const meta: Meta = { diagnostics: [], defaultName: "", defaultOwner: MockUserOwner, + owner: MockUserOwner, + setOwner: () => {}, externalAuth: [], externalAuthPollingState: {}, hasAllRequiredExternalAuth: true, @@ -456,3 +462,41 @@ export const WithUrlPresetOverridesDefault: Story = { ).toBeInTheDocument(); }, }; + +// When an admin creates a workspace for another user, the external auth section +// reflects that owner's state. The requester cannot authenticate on their +// behalf, so the login buttons are replaced with a read-only status. +export const ExternalAuthForAnotherUser: Story = { + args: { + owner: MockUserMember, + hasAllRequiredExternalAuth: false, + externalAuth: [ + { + id: "github", + type: "github", + display_name: "GitHub", + display_icon: "/icon/github.svg", + authenticate_url: "", + authenticated: true, + }, + { + id: "gitlab", + type: "gitlab", + display_name: "GitLab", + display_icon: "/icon/gitlab.svg", + authenticate_url: "", + authenticated: false, + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByText(/must connect any required providers themselves/i), + ).toBeInTheDocument(); + expect(canvas.getByText("Not connected")).toBeInTheDocument(); + expect( + canvas.queryByRole("button", { name: /login with/i }), + ).not.toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 4f449485fc7..520adabb008 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -392,6 +392,11 @@ export const CreateWorkspacePageView: FC = ({ ), ); + // External auth is connected to the workspace owner. When creating a + // workspace for another user, the form reflects that owner's auth state and + // the requester cannot authenticate on their behalf. + const isCreatingForSelf = owner.id === defaultOwner.id; + return ( <>
@@ -583,11 +588,19 @@ export const CreateWorkspacePageView: FC = ({ all required external authentication providers listed below. )} + {!isCreatingForSelf && ( + + This shows the external authentication state for{" "} + {owner.username}. They must connect any required providers + themselves; you can't authenticate on their behalf. + + )} {externalAuth.map((auth) => ( startPollingExternalAuth(auth.id)} displayRetry={ diff --git a/site/src/pages/CreateWorkspacePage/ExternalAuthButton.stories.tsx b/site/src/pages/CreateWorkspacePage/ExternalAuthButton.stories.tsx index acce4aa7430..61d103b3339 100644 --- a/site/src/pages/CreateWorkspacePage/ExternalAuthButton.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/ExternalAuthButton.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; import type { TemplateVersionExternalAuth } from "#/api/typesGenerated"; import { ExternalAuthButton } from "./ExternalAuthButton"; @@ -118,3 +119,34 @@ export const BitbucketAuthenticated: Story = { }, }, }; + +// When an admin creates a workspace for another user, the requester cannot +// authenticate on the owner's behalf, so the login action is hidden and the +// unconnected state is read-only. +export const ForAnotherUserNotConnected: Story = { + args: { + auth: MockExternalAuth, + canAuthenticate: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Not connected")).toBeInTheDocument(); + expect( + canvas.queryByRole("button", { name: /login with github/i }), + ).not.toBeInTheDocument(); + }, +}; + +export const ForAnotherUserAuthenticated: Story = { + args: { + auth: { + ...MockExternalAuth, + authenticated: true, + }, + canAuthenticate: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Authenticated")).toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/CreateWorkspacePage/ExternalAuthButton.tsx b/site/src/pages/CreateWorkspacePage/ExternalAuthButton.tsx index 443bc87c9dd..6057108106e 100644 --- a/site/src/pages/CreateWorkspacePage/ExternalAuthButton.tsx +++ b/site/src/pages/CreateWorkspacePage/ExternalAuthButton.tsx @@ -17,6 +17,11 @@ interface ExternalAuthButtonProps { isLoading: boolean; onStartPolling: () => void; error?: unknown; + /** + * Users can only connect external auth for themselves. An admin creating a + * workspace for someone else should just be shown the status. + */ + canAuthenticate?: boolean; } export const ExternalAuthButton: FC = ({ @@ -25,6 +30,7 @@ export const ExternalAuthButton: FC = ({ isLoading, onStartPolling, error, + canAuthenticate = true, }) => { return (
@@ -52,37 +58,47 @@ export const ExternalAuthButton: FC = ({ Authenticated

- ) : ( - - )} + ) : canAuthenticate ? ( + <> + - {displayRetry && !auth.authenticated && ( - - - - - - Retry login with {auth.display_name} - - + {displayRetry && ( + + + + + + Retry login with {auth.display_name} + + + )} + + ) : ( +

+ Not connected +

)}