diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 6e76d28383..29e7788106 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -3745,6 +3745,69 @@ const docTemplate = `{ } } }, + "/organizations/{organization}/members/{user}/workspaces/available-users": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Workspaces" + ], + "summary": "Get users available for workspace creation", + "operationId": "get-users-available-for-workspace-creation", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Search query", + "name": "q", + "in": "query" + }, + { + "type": "integer", + "description": "Limit results", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset for pagination", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.MinimalUser" + } + } + } + } + } + }, "/organizations/{organization}/paginated-members": { "get": { "security": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index b90ad878e1..350116ecd9 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -3296,6 +3296,65 @@ } } }, + "/organizations/{organization}/members/{user}/workspaces/available-users": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Workspaces"], + "summary": "Get users available for workspace creation", + "operationId": "get-users-available-for-workspace-creation", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Search query", + "name": "q", + "in": "query" + }, + { + "type": "integer", + "description": "Limit results", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset for pagination", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.MinimalUser" + } + } + } + } + } + }, "/organizations/{organization}/paginated-members": { "get": { "security": [ diff --git a/coderd/coderd.go b/coderd/coderd.go index 24a9e7c531..3594956bbe 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1232,7 +1232,10 @@ func New(options *Options) *API { r.Get("/", api.organizationMember) r.Delete("/", api.deleteOrganizationMember) r.Put("/roles", api.putMemberRoles) - r.Post("/workspaces", api.postWorkspacesByOrganization) + r.Route("/workspaces", func(r chi.Router) { + r.Post("/", api.postWorkspacesByOrganization) + r.Get("/available-users", api.workspaceAvailableUsers) + }) }) }) }) diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index f08930054d..0cf67f3cf0 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -265,6 +265,16 @@ func TestRolePermissions(t *testing.T) { false: {setOtherOrg, memberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgAdminBanWorkspace}, }, }, + { + Name: "CreateWorkspaceForMembers", + // When creating the WithID won't be set, but it does not change the result. + Actions: []policy.Action{policy.ActionCreate}, + Resource: rbac.ResourceWorkspace.InOrg(orgID).WithOwner(policy.WildcardSymbol), + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {owner, orgAdmin}, + false: {setOtherOrg, orgUserAdmin, orgAuditor, memberMe, userAdmin, templateAdmin, orgTemplateAdmin}, + }, + }, { Name: "MyWorkspaceInOrgExecution", // When creating the WithID won't be set, but it does not change the result. diff --git a/coderd/workspaces.go b/coderd/workspaces.go index c4461fefd0..1077cafe6a 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -2952,3 +2952,48 @@ func convertToWorkspaceRole(actions []policy.Action) codersdk.WorkspaceRole { return codersdk.WorkspaceRoleDeleted } + +// @Summary Get users available for workspace creation +// @ID get-users-available-for-workspace-creation +// @Security CoderSessionToken +// @Produce json +// @Tags Workspaces +// @Param organization path string true "Organization ID" format(uuid) +// @Param user path string true "User ID, name, or me" +// @Param q query string false "Search query" +// @Param limit query int false "Limit results" +// @Param offset query int false "Offset for pagination" +// @Success 200 {array} codersdk.MinimalUser +// @Router /organizations/{organization}/members/{user}/workspaces/available-users [get] +func (api *API) workspaceAvailableUsers(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + organization := httpmw.OrganizationParam(r) + + // This endpoint requires the user to be able to create workspaces for other + // users in this organization. We check if they can create a workspace with + // a wildcard owner. + if !api.Authorize(r, policy.ActionCreate, rbac.ResourceWorkspace.InOrg(organization.ID).WithOwner(policy.WildcardSymbol)) { + httpapi.Forbidden(rw) + return + } + + // Use system context to list all users. The authorization check above + // ensures only users who can create workspaces for others can access this. + //nolint:gocritic // System context needed to list users for workspace owner selection. + users, _, ok := api.GetUsers(rw, r.WithContext(dbauthz.AsSystemRestricted(ctx))) + if !ok { + return + } + + minimalUsers := make([]codersdk.MinimalUser, 0, len(users)) + for _, user := range users { + minimalUsers = append(minimalUsers, codersdk.MinimalUser{ + ID: user.ID, + Username: user.Username, + Name: user.Name, + AvatarURL: user.AvatarURL, + }) + } + + httpapi.Write(ctx, rw, http.StatusOK, minimalUsers) +} diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 5a6626cdc3..e09ea3a29b 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -5625,6 +5625,54 @@ func TestWorkspaceSharingDisabled(t *testing.T) { }) } +func TestWorkspaceAvailableUsers(t *testing.T) { + t.Parallel() + + t.Run("OrgAdminCanListUsers", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + + ctx := testutil.Context(t, testutil.WaitMedium) + + // Create an org admin and additional users + orgAdminClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.ScopedRoleOrgAdmin(owner.OrganizationID)) + _, user1 := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + _, user2 := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + // Org admin should be able to list available users + users, err := orgAdminClient.WorkspaceAvailableUsers(ctx, owner.OrganizationID, "me") + require.NoError(t, err) + require.GreaterOrEqual(t, len(users), 4) // owner + orgAdmin + 2 users + + // Verify the users we created are in the list + usernames := make([]string, 0, len(users)) + for _, u := range users { + usernames = append(usernames, u.Username) + } + require.Contains(t, usernames, user1.Username) + require.Contains(t, usernames, user2.Username) + }) + + t.Run("MemberCannotListUsers", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + + ctx := testutil.Context(t, testutil.WaitMedium) + + // Create a regular member + memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + // Regular member should not be able to list available users + _, err := memberClient.WorkspaceAvailableUsers(ctx, owner.OrganizationID, "me") + require.Error(t, err) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) + }) +} + func TestWorkspaceCreateWithImplicitPreset(t *testing.T) { t.Parallel() diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 4a93014c79..bf588722b8 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -787,3 +787,19 @@ func (c *Client) WorkspaceExternalAgentCredentials(ctx context.Context, workspac var credentials ExternalAgentCredentials return credentials, json.NewDecoder(res.Body).Decode(&credentials) } + +// WorkspaceAvailableUsers returns users available for workspace creation. +// This is used to populate the owner dropdown when creating workspaces for +// other users. +func (c *Client) WorkspaceAvailableUsers(ctx context.Context, organizationID uuid.UUID, userID string) ([]MinimalUser, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/members/%s/workspaces/available-users", organizationID, userID), nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + var users []MinimalUser + return users, json.NewDecoder(res.Body).Decode(&users) +} diff --git a/docs/reference/api/workspaces.md b/docs/reference/api/workspaces.md index 43bf00bc43..9d54ee018d 100644 --- a/docs/reference/api/workspaces.md +++ b/docs/reference/api/workspaces.md @@ -331,6 +331,64 @@ of the template will be used. To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get users available for workspace creation + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members/{user}/workspaces/available-users \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /organizations/{organization}/members/{user}/workspaces/available-users` + +### Parameters + +| Name | In | Type | Required | Description | +|----------------|-------|--------------|----------|-----------------------| +| `organization` | path | string(uuid) | true | Organization ID | +| `user` | path | string | true | User ID, name, or me | +| `q` | query | string | false | Search query | +| `limit` | query | integer | false | Limit results | +| `offset` | query | integer | false | Offset for pagination | + +### Example responses + +> 200 Response + +```json +[ + { + "avatar_url": "http://example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "username": "string" + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|-----------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.MinimalUser](schemas.md#codersdkminimaluser) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +|----------------|--------------|----------|--------------|-------------| +| `[array item]` | array | false | | | +| `» avatar_url` | string(uri) | false | | | +| `» id` | string(uuid) | true | | | +| `» name` | string | false | | | +| `» username` | string | true | | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get workspace metadata by user and workspace name ### Code samples diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 203ba1855a..48ab1749ad 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -569,6 +569,28 @@ class ApiMethods { return response.data; }; + /** + * Get users for workspace owner selection. Requires + * permission to create workspaces for other users in the + * organization. Returns minimal user data (no email, roles, + * etc.). + */ + getWorkspaceAvailableUsers = async ( + organizationId: string, + options: TypesGen.UsersRequest, + signal?: AbortSignal, + ): Promise => { + const url = getURLWithSearchParams( + `/api/v2/organizations/${organizationId}/members/me/workspaces/available-users`, + options, + ); + const response = await this.axios.get( + url.toString(), + { signal }, + ); + return response.data; + }; + createOrganization = async (params: TypesGen.CreateOrganizationRequest) => { const response = await this.axios.post( "/api/v2/organizations", diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index e6ec7e3364..9287bf3f23 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -3,6 +3,7 @@ import type { AuthorizationRequest, GenerateAPIKeyResponse, GetUsersResponse, + MinimalUser, RequestOneTimePasscodeRequest, UpdateUserAppearanceSettingsRequest, UpdateUserPasswordRequest, @@ -58,6 +59,18 @@ export const users = (req: UsersRequest): UseQueryOptions => { }; }; +export const workspaceAvailableUsers = ( + organizationId: string, + req: UsersRequest, +): UseQueryOptions => { + return { + queryKey: ["workspaceAvailableUsers", organizationId, req], + queryFn: ({ signal }) => + API.getWorkspaceAvailableUsers(organizationId, req, signal), + gcTime: 5 * 1000 * 60, + }; +}; + export const updatePassword = () => { return { mutationFn: ({ diff --git a/site/src/components/UserAutocomplete/UserAutocomplete.tsx b/site/src/components/UserAutocomplete/UserAutocomplete.tsx index 21a7fce03a..0db917b47a 100644 --- a/site/src/components/UserAutocomplete/UserAutocomplete.tsx +++ b/site/src/components/UserAutocomplete/UserAutocomplete.tsx @@ -4,8 +4,12 @@ import CircularProgress from "@mui/material/CircularProgress"; import TextField from "@mui/material/TextField"; import { getErrorMessage } from "api/errors"; import { organizationMembers } from "api/queries/organizations"; -import { users } from "api/queries/users"; -import type { OrganizationMemberWithUserData, User } from "api/typesGenerated"; +import { users, workspaceAvailableUsers } from "api/queries/users"; +import type { + MinimalUser, + OrganizationMemberWithUserData, + User, +} from "api/typesGenerated"; import { Avatar } from "components/Avatar/Avatar"; import { AvatarData } from "components/Avatar/AvatarData"; import { useDebouncedFunction } from "hooks/debounce"; @@ -21,7 +25,7 @@ import { prepareQuery } from "utils/filters"; // The common properties between users and org members that we need. type SelectedUser = { avatar_url?: string; - email: string; + email?: string; username: string; }; @@ -85,6 +89,35 @@ export const MemberAutocomplete: FC = ({ ); }; +type WorkspaceUserAutocompleteProps = CommonAutocompleteProps & { + organizationId: string; +}; + +export const WorkspaceUserAutocomplete: FC = ({ + organizationId, + ...props +}) => { + const [filter, setFilter] = useState(); + + const availableUsersQuery = useQuery({ + ...workspaceAvailableUsers(organizationId, { + q: prepareQuery(encodeURI(filter ?? "")), + limit: 25, + }), + enabled: filter !== undefined, + placeholderData: keepPreviousData, + }); + return ( + + error={availableUsersQuery.error} + isFetching={availableUsersQuery.isFetching} + setFilter={setFilter} + users={availableUsersQuery.data} + {...props} + /> + ); +}; + type InnerAutocompleteProps = CommonAutocompleteProps & { /** The error is null if not loaded or no error. */ @@ -131,10 +164,10 @@ const InnerAutocomplete = ({ data-testid="user-autocomplete" open={open} isOptionEqualToValue={(a, b) => a.username === b.username} - getOptionLabel={(option) => option.email} + getOptionLabel={(option) => option.email ?? option.username} onOpen={() => { setOpen(true); - setFilter(value?.email ?? ""); + setFilter(value?.email ?? value?.username ?? ""); }} onClose={() => { setOpen(false); @@ -159,7 +192,7 @@ const InnerAutocomplete = ({ fullWidth size={size} label={label} - placeholder="User email or username" + placeholder="Username or email" css={{ "&:not(:has(label))": { margin: 0, diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index b3fbb46b6c..e276fd7293 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -11,6 +11,7 @@ import { autoCreateWorkspace, createWorkspace } from "api/queries/workspaces"; import type { DynamicParametersRequest, DynamicParametersResponse, + MinimalUser, PreviewParameter, Workspace, } from "api/typesGenerated"; @@ -63,8 +64,8 @@ const CreateWorkspacePage: FC = () => { const [autoCreateConsented, setAutoCreateConsented] = useState(false); const [autoCreateError, setAutoCreateError] = useState(null); - const defaultOwner = me; - const [owner, setOwner] = useState(defaultOwner); + const defaultOwner: MinimalUser = me; + const [owner, setOwner] = useState(defaultOwner); const queryClient = useQueryClient(); const autoCreateWorkspaceMutation = useMutation( diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 6474c4c1b4..1732c0a7c4 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -22,7 +22,7 @@ import { TooltipContent, TooltipTrigger, } from "components/Tooltip/Tooltip"; -import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; +import { WorkspaceUserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; import { type FormikContextType, useFormik } from "formik"; import { useDebouncedFunction } from "hooks/debounce"; import type { ExternalAuthPollingState } from "hooks/useExternalAuth"; @@ -57,7 +57,7 @@ interface CreateWorkspacePageViewProps { canUpdateTemplate?: boolean; creatingWorkspace: boolean; defaultName?: string | null; - defaultOwner: TypesGen.User; + defaultOwner: TypesGen.MinimalUser; diagnostics: readonly FriendlyDiagnostic[]; disabledParams?: string[]; error: unknown; @@ -74,13 +74,13 @@ interface CreateWorkspacePageViewProps { onCancel: () => void; onSubmit: ( req: TypesGen.CreateWorkspaceRequest, - owner: TypesGen.User, + owner: TypesGen.MinimalUser, ) => void; resetMutation: () => void; sendMessage: (message: Record, ownerId?: string) => void; startPollingExternalAuth: () => void; - owner: TypesGen.User; - setOwner: (user: TypesGen.User) => void; + owner: TypesGen.MinimalUser; + setOwner: (user: TypesGen.MinimalUser) => void; } export const CreateWorkspacePageView: FC = ({ @@ -313,7 +313,7 @@ export const CreateWorkspacePageView: FC = ({ sendDynamicParamsRequest, ]); - const handleOwnerChange = (user: TypesGen.User) => { + const handleOwnerChange = (user: TypesGen.MinimalUser) => { setOwner(user); sendDynamicParamsRequest([], user.id); }; @@ -503,7 +503,8 @@ export const CreateWorkspacePageView: FC = ({ - { handleOwnerChange(user ?? defaultOwner);