mirror of
https://github.com/coder/coder.git
synced 2026-09-01 14:53:15 +08:00
fix: show selected owner's external auth when creating a workspace (#26653)
This commit is contained in:
Generated
+7
@@ -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": {
|
||||
|
||||
Generated
+7
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ const (
|
||||
SubjectTypeChatd SubjectType = "chatd"
|
||||
SubjectTypeAIProviderMetadataReader SubjectType = "ai_provider_metadata_reader"
|
||||
SubjectTypeSCIMProvisioner SubjectType = "scim_provisioner"
|
||||
SubjectTypeExternalAuthCoordinator SubjectType = "external_auth_coordinator"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Generated
+4
-3
@@ -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
|
||||
|
||||
|
||||
+2
-1
@@ -1160,9 +1160,10 @@ class ApiMethods {
|
||||
|
||||
getTemplateVersionExternalAuth = async (
|
||||
versionId: string,
|
||||
userId = "me",
|
||||
): Promise<TypesGen.TemplateVersionExternalAuth[]> => {
|
||||
const response = await this.axios.get(
|
||||
`/api/v2/templateversions/${versionId}/external-auth`,
|
||||
`/api/v2/templateversions/${versionId}/external-auth?user_id=${userId}`,
|
||||
);
|
||||
|
||||
return response.data;
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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<string, ExternalAuthPollingState>
|
||||
>({});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -179,7 +179,7 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ 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<ExternalAuthButtonProps> = ({
|
||||
missedExternalAuth,
|
||||
}) => {
|
||||
const { startPollingExternalAuth, externalAuthPollingState } =
|
||||
useExternalAuth(versionId);
|
||||
useExternalAuth(versionId, "me");
|
||||
|
||||
return missedExternalAuth.map((auth) => {
|
||||
const isPollingExternalAuth =
|
||||
|
||||
@@ -254,7 +254,7 @@ const CreateWorkspacePage: FC = () => {
|
||||
externalAuthPollingState,
|
||||
startPollingExternalAuth,
|
||||
isLoadingExternalAuth,
|
||||
} = useExternalAuth(realizedVersionId);
|
||||
} = useExternalAuth(realizedVersionId, owner.id);
|
||||
|
||||
const isLoadingFormData =
|
||||
ws.current?.readyState === WebSocket.CONNECTING ||
|
||||
|
||||
@@ -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<typeof CreateWorkspacePageView> = {
|
||||
@@ -15,6 +19,8 @@ const meta: Meta<typeof CreateWorkspacePageView> = {
|
||||
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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -392,6 +392,11 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
|
||||
),
|
||||
);
|
||||
|
||||
// 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 (
|
||||
<>
|
||||
<div className="sticky top-5 ml-10">
|
||||
@@ -583,11 +588,19 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
|
||||
all required external authentication providers listed below.
|
||||
</Alert>
|
||||
)}
|
||||
{!isCreatingForSelf && (
|
||||
<Alert severity="info">
|
||||
This shows the external authentication state for{" "}
|
||||
{owner.username}. They must connect any required providers
|
||||
themselves; you can't authenticate on their behalf.
|
||||
</Alert>
|
||||
)}
|
||||
{externalAuth.map((auth) => (
|
||||
<ExternalAuthButton
|
||||
key={auth.id}
|
||||
error={error}
|
||||
auth={auth}
|
||||
canAuthenticate={isCreatingForSelf}
|
||||
isLoading={externalAuthPollingState[auth.id] === "polling"}
|
||||
onStartPolling={() => startPollingExternalAuth(auth.id)}
|
||||
displayRetry={
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<ExternalAuthButtonProps> = ({
|
||||
@@ -25,6 +30,7 @@ export const ExternalAuthButton: FC<ExternalAuthButtonProps> = ({
|
||||
isLoading,
|
||||
onStartPolling,
|
||||
error,
|
||||
canAuthenticate = true,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2 border border-border border-solid rounded-md p-3 justify-between">
|
||||
@@ -52,37 +58,47 @@ export const ExternalAuthButton: FC<ExternalAuthButtonProps> = ({
|
||||
Authenticated
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={isLoading || auth.authenticated}
|
||||
onClick={() => {
|
||||
window.open(
|
||||
auth.authenticate_url,
|
||||
"_blank",
|
||||
"width=900,height=600",
|
||||
);
|
||||
onStartPolling();
|
||||
}}
|
||||
>
|
||||
<Spinner loading={isLoading} />
|
||||
Login with {auth.display_name}
|
||||
</Button>
|
||||
)}
|
||||
) : canAuthenticate ? (
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={isLoading || auth.authenticated}
|
||||
onClick={() => {
|
||||
window.open(
|
||||
auth.authenticate_url,
|
||||
"_blank",
|
||||
"width=900,height=600",
|
||||
);
|
||||
onStartPolling();
|
||||
}}
|
||||
>
|
||||
<Spinner loading={isLoading} />
|
||||
Login with {auth.display_name}
|
||||
</Button>
|
||||
|
||||
{displayRetry && !auth.authenticated && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="icon" onClick={onStartPolling}>
|
||||
<RedoIcon />
|
||||
<span className="sr-only">Refresh external auth</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Retry login with {auth.display_name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{displayRetry && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onStartPolling}
|
||||
>
|
||||
<RedoIcon />
|
||||
<span className="sr-only">Refresh external auth</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Retry login with {auth.display_name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs font-semibold text-content-secondary m-0">
|
||||
Not connected
|
||||
</p>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user