mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
feat: add user workspace search filter (#29442)
This commit is contained in:
@@ -344,6 +344,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa
|
||||
arg.Shared,
|
||||
arg.SharedWithUserID,
|
||||
arg.SharedWithGroupID,
|
||||
arg.UserID,
|
||||
arg.RequesterID,
|
||||
arg.Offset,
|
||||
arg.Limit,
|
||||
|
||||
Generated
+21
-5
@@ -39735,6 +39735,20 @@ WHERE
|
||||
workspaces.group_acl ? ($23 :: uuid) :: text
|
||||
ELSE true
|
||||
END
|
||||
-- Filter by user_id: workspaces the user owns, or that are shared with
|
||||
-- them directly or through a group they belong to.
|
||||
AND CASE
|
||||
WHEN $24 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
|
||||
workspaces.owner_id = $24
|
||||
OR workspaces.user_acl ? ($24 :: uuid) :: text
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM group_members_expanded
|
||||
WHERE group_members_expanded.user_id = $24
|
||||
AND workspaces.group_acl ? group_members_expanded.group_id :: text
|
||||
)
|
||||
ELSE true
|
||||
END
|
||||
|
||||
-- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces
|
||||
-- @authorize_filter
|
||||
@@ -39745,7 +39759,7 @@ WHERE
|
||||
filtered_workspaces fw
|
||||
ORDER BY
|
||||
-- To ensure that 'favorite' workspaces show up first in the list only for their owner.
|
||||
CASE WHEN favorite AND owner_username = (SELECT users.username FROM users WHERE users.id = $24) THEN 0 ELSE 1 END ASC,
|
||||
CASE WHEN favorite AND owner_username = (SELECT users.username FROM users WHERE users.id = $25) THEN 0 ELSE 1 END ASC,
|
||||
(latest_build_completed_at IS NOT NULL AND
|
||||
latest_build_canceled_at IS NULL AND
|
||||
latest_build_error IS NULL AND
|
||||
@@ -39754,11 +39768,11 @@ WHERE
|
||||
LOWER(name) ASC
|
||||
LIMIT
|
||||
CASE
|
||||
WHEN $26 :: integer > 0 THEN
|
||||
$26
|
||||
WHEN $27 :: integer > 0 THEN
|
||||
$27
|
||||
END
|
||||
OFFSET
|
||||
$25
|
||||
$26
|
||||
), filtered_workspaces_order_with_summary AS (
|
||||
SELECT
|
||||
fwo.id, fwo.created_at, fwo.updated_at, fwo.owner_id, fwo.organization_id, fwo.template_id, fwo.deleted, fwo.name, fwo.autostart_schedule, fwo.ttl, fwo.last_used_at, fwo.dormant_at, fwo.deleting_at, fwo.automatic_updates, fwo.favorite, fwo.next_start_at, fwo.group_acl, fwo.user_acl, fwo.owner_avatar_url, fwo.owner_username, fwo.owner_name, fwo.organization_name, fwo.organization_display_name, fwo.organization_icon, fwo.organization_description, fwo.template_name, fwo.template_display_name, fwo.template_icon, fwo.template_description, fwo.group_acl_display_info, fwo.user_acl_display_info, fwo.template_version_id, fwo.template_version_name, fwo.latest_build_completed_at, fwo.latest_build_canceled_at, fwo.latest_build_error, fwo.latest_build_transition, fwo.latest_build_status, fwo.latest_build_has_external_agent, fwo.latest_build_provisioner_job_id
|
||||
@@ -39810,7 +39824,7 @@ WHERE
|
||||
false, -- latest_build_has_external_agent
|
||||
'00000000-0000-0000-0000-000000000000'::uuid -- latest_build_provisioner_job_id
|
||||
WHERE
|
||||
$27 :: boolean = true
|
||||
$28 :: boolean = true
|
||||
), total_count AS (
|
||||
SELECT
|
||||
count(*) AS count
|
||||
@@ -39903,6 +39917,7 @@ type GetWorkspacesParams struct {
|
||||
Shared sql.NullBool `db:"shared" json:"shared"`
|
||||
SharedWithUserID uuid.UUID `db:"shared_with_user_id" json:"shared_with_user_id"`
|
||||
SharedWithGroupID uuid.UUID `db:"shared_with_group_id" json:"shared_with_group_id"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
RequesterID uuid.UUID `db:"requester_id" json:"requester_id"`
|
||||
Offset int32 `db:"offset_" json:"offset_"`
|
||||
Limit int32 `db:"limit_" json:"limit_"`
|
||||
@@ -39982,6 +39997,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams)
|
||||
arg.Shared,
|
||||
arg.SharedWithUserID,
|
||||
arg.SharedWithGroupID,
|
||||
arg.UserID,
|
||||
arg.RequesterID,
|
||||
arg.Offset,
|
||||
arg.Limit,
|
||||
|
||||
@@ -374,6 +374,20 @@ WHERE
|
||||
workspaces.group_acl ? (@shared_with_group_id :: uuid) :: text
|
||||
ELSE true
|
||||
END
|
||||
-- Filter by user_id: workspaces the user owns, or that are shared with
|
||||
-- them directly or through a group they belong to.
|
||||
AND CASE
|
||||
WHEN @user_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
|
||||
workspaces.owner_id = @user_id
|
||||
OR workspaces.user_acl ? (@user_id :: uuid) :: text
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM group_members_expanded
|
||||
WHERE group_members_expanded.user_id = @user_id
|
||||
AND workspaces.group_acl ? group_members_expanded.group_id :: text
|
||||
)
|
||||
ELSE true
|
||||
END
|
||||
|
||||
-- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces
|
||||
-- @authorize_filter
|
||||
|
||||
@@ -304,6 +304,7 @@ func Workspaces(ctx context.Context, db database.Store, query string, page coder
|
||||
filter.Shared = parser.NullableBoolean(values, sql.NullBool{}, "shared")
|
||||
filter.SharedWithUserID = parseUser(ctx, db, parser, values, "shared_with_user", actorID)
|
||||
filter.SharedWithGroupID = parseGroup(ctx, db, parser, values, "shared_with_group")
|
||||
filter.UserID = parseUser(ctx, db, parser, values, "user", actorID)
|
||||
// Translate healthy filter to has-agent statuses
|
||||
// healthy:true = connected, healthy:false = disconnected or timeout
|
||||
if healthy := parser.NullableBoolean(values, sql.NullBool{}, "healthy"); healthy.Valid {
|
||||
|
||||
@@ -356,6 +356,32 @@ func TestSearchWorkspace(t *testing.T) {
|
||||
SharedWithUserID: uuid.MustParse("3dd8b1b8-dff5-4b22-8ae9-c243ca136ecf"),
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "UserMe",
|
||||
Query: `user:me`,
|
||||
Setup: func(t *testing.T, db database.Store) {
|
||||
dbgen.User(t, db, database.User{
|
||||
ID: uuid.MustParse("3dd8b1b8-dff5-4b22-8ae9-c243ca136ecf"),
|
||||
})
|
||||
},
|
||||
ActorID: uuid.MustParse("3dd8b1b8-dff5-4b22-8ae9-c243ca136ecf"),
|
||||
Expected: database.GetWorkspacesParams{
|
||||
UserID: uuid.MustParse("3dd8b1b8-dff5-4b22-8ae9-c243ca136ecf"),
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "UserByName",
|
||||
Query: `user:wibble`,
|
||||
Setup: func(t *testing.T, db database.Store) {
|
||||
dbgen.User(t, db, database.User{
|
||||
ID: uuid.MustParse("3dd8b1b8-dff5-4b22-8ae9-c243ca136ecf"),
|
||||
Username: "wibble",
|
||||
})
|
||||
},
|
||||
Expected: database.GetWorkspacesParams{
|
||||
UserID: uuid.MustParse("3dd8b1b8-dff5-4b22-8ae9-c243ca136ecf"),
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "SharedWithGroupDefaultOrg",
|
||||
Query: "shared_with_group:wibble",
|
||||
|
||||
@@ -2970,6 +2970,73 @@ func TestWorkspaceFilterManual(t *testing.T) {
|
||||
expectIDs(t, []codersdk.Workspace{foo, bar, baz}, all.Workspaces)
|
||||
})
|
||||
})
|
||||
|
||||
// The user filter matches workspaces the user owns, plus workspaces
|
||||
// shared with them directly or through a group they belong to.
|
||||
t.Run("User", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
client, db = coderdtest.NewWithDatabase(t, nil)
|
||||
orgOwner = coderdtest.CreateFirstUser(t, client)
|
||||
_, workspaceOwner = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID)
|
||||
userClient, user = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID)
|
||||
group = dbgen.Group(t, db, database.Group{OrganizationID: orgOwner.OrganizationID})
|
||||
ownedWorkspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OwnerID: user.ID,
|
||||
OrganizationID: orgOwner.OrganizationID,
|
||||
}).Do().Workspace
|
||||
userSharedWorkspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OwnerID: workspaceOwner.ID,
|
||||
OrganizationID: orgOwner.OrganizationID,
|
||||
}).Do().Workspace
|
||||
groupSharedWorkspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OwnerID: workspaceOwner.ID,
|
||||
OrganizationID: orgOwner.OrganizationID,
|
||||
}).Do().Workspace
|
||||
_ = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OwnerID: workspaceOwner.ID,
|
||||
OrganizationID: orgOwner.OrganizationID,
|
||||
}).Do().Workspace
|
||||
ctx = testutil.Context(t, testutil.WaitMedium)
|
||||
)
|
||||
|
||||
dbgen.GroupMember(t, db, database.GroupMemberTable{
|
||||
GroupID: group.ID,
|
||||
UserID: user.ID,
|
||||
})
|
||||
|
||||
err := client.UpdateWorkspaceACL(ctx, userSharedWorkspace.ID, codersdk.UpdateWorkspaceACL{
|
||||
UserRoles: map[string]codersdk.WorkspaceRole{
|
||||
user.ID.String(): codersdk.WorkspaceRoleUse,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = client.UpdateWorkspaceACL(ctx, groupSharedWorkspace.ID, codersdk.UpdateWorkspaceACL{
|
||||
GroupRoles: map[string]codersdk.WorkspaceRole{
|
||||
group.ID.String(): codersdk.WorkspaceRoleUse,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := []codersdk.Workspace{
|
||||
{ID: ownedWorkspace.ID},
|
||||
{ID: userSharedWorkspace.ID},
|
||||
{ID: groupSharedWorkspace.ID},
|
||||
}
|
||||
|
||||
byUsername, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
|
||||
User: user.Username,
|
||||
})
|
||||
require.NoError(t, err, "fetch workspaces by username")
|
||||
expectIDs(t, expected, byUsername.Workspaces)
|
||||
|
||||
asMe, err := userClient.Workspaces(ctx, codersdk.WorkspaceFilter{
|
||||
User: codersdk.Me,
|
||||
})
|
||||
require.NoError(t, err, "fetch workspaces as me")
|
||||
expectIDs(t, expected, asMe.Workspaces)
|
||||
})
|
||||
}
|
||||
|
||||
func TestOffsetLimit(t *testing.T) {
|
||||
|
||||
@@ -559,6 +559,9 @@ type WorkspaceFilter struct {
|
||||
SharedWithUser string `json:"shared_with_user,omitempty" typescript:"-"`
|
||||
// SharedWithGroup is the group name, group ID, or <org name>/<group name> of the group that the workspace is shared with
|
||||
SharedWithGroup string `json:"shared_with_group,omitempty" typescript:"-"`
|
||||
// User is "me", a username, or a user ID. It matches workspaces the user
|
||||
// owns or that are shared with them directly or through a group.
|
||||
User string `json:"user,omitempty" typescript:"-"`
|
||||
// IncludeAgentMetadata expands each agent in the response with the
|
||||
// named metadata keys. It does not filter the returned workspaces.
|
||||
IncludeAgentMetadata []string `json:"include_agent_metadata,omitempty" typescript:"-"`
|
||||
@@ -597,6 +600,9 @@ func (f WorkspaceFilter) asRequestOption() RequestOption {
|
||||
if f.SharedWithGroup != "" {
|
||||
params = append(params, fmt.Sprintf("shared_with_group:%q", f.SharedWithGroup))
|
||||
}
|
||||
if f.User != "" {
|
||||
params = append(params, fmt.Sprintf("user:%q", f.User))
|
||||
}
|
||||
for _, key := range f.IncludeAgentMetadata {
|
||||
params = append(params, fmt.Sprintf("include_agent_metadata:%q", key))
|
||||
}
|
||||
|
||||
@@ -56,6 +56,11 @@ To list shared workspaces:
|
||||
- `coder list --search shared_with_user:<user>`
|
||||
- `coder list --search shared_with_group:<group>`
|
||||
|
||||
To list the workspaces you own together with the ones shared with you,
|
||||
directly or through a group:
|
||||
|
||||
- `coder list --search user:me`
|
||||
|
||||
### UI
|
||||
|
||||
#### Sharing your Workspace
|
||||
|
||||
@@ -61,6 +61,9 @@ The following filters are supported:
|
||||
|
||||
- `owner` - Represents the `username` of the owner. You can also use `me` as a
|
||||
convenient alias for the logged-in user, e.g., `owner:me`
|
||||
- `user` - Workspaces a user owns, plus workspaces shared with them directly
|
||||
or through a group they belong to. Accepts a `username`, user ID, or `me`,
|
||||
e.g., `user:me`
|
||||
- `name` - Name of the workspace.
|
||||
- `template` - Name of the template.
|
||||
- `status` - Indicates the status of the workspace, e.g, `status:failed` For a
|
||||
|
||||
Reference in New Issue
Block a user