Skip to content

Commit dcab6fa

Browse files
feat(site): display user avatar (#11893)
* add owner API to workspace and workspace build responses * display user avatar in workspace top bar Co-authored-by: Cian Johnston <cian@coder.com>
1 parent 83eea2d commit dcab6fa

18 files changed

+216
-105
lines changed

cli/testdata/coder_list_--output_json.golden

+2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"updated_at": "[timestamp]",
66
"owner_id": "[first user ID]",
77
"owner_name": "testuser",
8+
"owner_avatar_url": "",
89
"organization_id": "[first org ID]",
910
"template_id": "[template ID]",
1011
"template_name": "test-template",
@@ -21,6 +22,7 @@
2122
"workspace_name": "test-workspace",
2223
"workspace_owner_id": "[first user ID]",
2324
"workspace_owner_name": "testuser",
25+
"workspace_owner_avatar_url": "",
2426
"template_version_id": "[version ID]",
2527
"template_version_name": "[version name]",
2628
"build_number": 1,

coderd/apidoc/docs.go

+6
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

+6
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/httpmw/organizationparam.go

+14-10
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,13 @@ func ExtractOrganizationParam(db database.Store) func(http.Handler) http.Handler
6363
}
6464
}
6565

66-
// OrganizationMember is the database object plus the Username. Including the Username in this
67-
// middleware is preferable to a join at the SQL layer so that we can keep the autogenerated
68-
// database types as they are.
66+
// OrganizationMember is the database object plus the Username and Avatar URL. Including these
67+
// in the middleware is preferable to a join at the SQL layer so that we can keep the
68+
// autogenerated database types as they are.
6969
type OrganizationMember struct {
7070
database.OrganizationMember
71-
Username string
71+
Username string
72+
AvatarURL string
7273
}
7374

7475
// ExtractOrganizationMemberParam grabs a user membership from the "organization" and "user" URL parameter.
@@ -107,14 +108,17 @@ func ExtractOrganizationMemberParam(db database.Store) func(http.Handler) http.H
107108

108109
ctx = context.WithValue(ctx, organizationMemberParamContextKey{}, OrganizationMember{
109110
OrganizationMember: organizationMember,
110-
// Here we're making one exception to the rule about not leaking data about the user
111-
// to the API handler, which is to include the username. If the caller has permission
112-
// to read the OrganizationMember, then we're explicitly saying here that they also
113-
// have permission to see the member's username, which is itself uncontroversial.
111+
// Here we're making two exceptions to the rule about not leaking data about the user
112+
// to the API handler, which is to include the username and avatar URL.
113+
// If the caller has permission to read the OrganizationMember, then we're explicitly
114+
// saying here that they also have permission to see the member's username and avatar.
115+
// This is OK!
114116
//
115117
// API handlers need this information for audit logging and returning the owner's
116-
// username in response to creating a workspace.
117-
Username: user.Username,
118+
// username in response to creating a workspace. Additionally, the frontend consumes
119+
// the Avatar URL and this allows the FE to avoid an extra request.
120+
Username: user.Username,
121+
AvatarURL: user.AvatarURL,
118122
})
119123
next.ServeHTTP(rw, r.WithContext(ctx))
120124
})

coderd/httpmw/organizationparam_test.go

+31-2
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,17 @@ import (
88

99
"github.com/go-chi/chi/v5"
1010
"github.com/google/uuid"
11+
"github.com/stretchr/testify/assert"
1112
"github.com/stretchr/testify/require"
1213

1314
"github.com/coder/coder/v2/coderd/database"
1415
"github.com/coder/coder/v2/coderd/database/dbgen"
1516
"github.com/coder/coder/v2/coderd/database/dbmem"
1617
"github.com/coder/coder/v2/coderd/database/dbtime"
1718
"github.com/coder/coder/v2/coderd/httpmw"
19+
"github.com/coder/coder/v2/coderd/rbac"
1820
"github.com/coder/coder/v2/codersdk"
21+
"github.com/coder/coder/v2/testutil"
1922
)
2023

2124
func TestOrganizationParam(t *testing.T) {
@@ -139,6 +142,7 @@ func TestOrganizationParam(t *testing.T) {
139142
t.Run("Success", func(t *testing.T) {
140143
t.Parallel()
141144
var (
145+
ctx = testutil.Context(t, testutil.WaitShort)
142146
db = dbmem.New()
143147
rw = httptest.NewRecorder()
144148
r, user = setupAuthentication(db)
@@ -148,7 +152,14 @@ func TestOrganizationParam(t *testing.T) {
148152
_ = dbgen.OrganizationMember(t, db, database.OrganizationMember{
149153
OrganizationID: organization.ID,
150154
UserID: user.ID,
155+
Roles: []string{rbac.RoleOrgMember(organization.ID)},
151156
})
157+
_, err := db.UpdateUserRoles(ctx, database.UpdateUserRolesParams{
158+
ID: user.ID,
159+
GrantedRoles: []string{rbac.RoleTemplateAdmin()},
160+
})
161+
require.NoError(t, err)
162+
152163
chi.RouteContext(r.Context()).URLParams.Add("organization", organization.ID.String())
153164
chi.RouteContext(r.Context()).URLParams.Add("user", user.ID.String())
154165
rtr.Use(
@@ -161,9 +172,27 @@ func TestOrganizationParam(t *testing.T) {
161172
httpmw.ExtractOrganizationMemberParam(db),
162173
)
163174
rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) {
164-
_ = httpmw.OrganizationParam(r)
165-
_ = httpmw.OrganizationMemberParam(r)
175+
org := httpmw.OrganizationParam(r)
176+
assert.NotZero(t, org)
177+
assert.NotZero(t, org.CreatedAt)
178+
// assert.NotZero(t, org.Description) // not supported
179+
assert.NotZero(t, org.ID)
180+
assert.NotEmpty(t, org.Name)
181+
orgMem := httpmw.OrganizationMemberParam(r)
166182
rw.WriteHeader(http.StatusOK)
183+
assert.NotZero(t, orgMem)
184+
assert.NotZero(t, orgMem.CreatedAt)
185+
assert.NotZero(t, orgMem.UpdatedAt)
186+
assert.Equal(t, org.ID, orgMem.OrganizationID)
187+
assert.Equal(t, user.ID, orgMem.UserID)
188+
assert.Equal(t, user.Username, orgMem.Username)
189+
assert.Equal(t, user.AvatarURL, orgMem.AvatarURL)
190+
assert.NotEmpty(t, orgMem.Roles)
191+
assert.NotZero(t, orgMem.OrganizationMember)
192+
assert.NotEmpty(t, orgMem.OrganizationMember.CreatedAt)
193+
assert.NotEmpty(t, orgMem.OrganizationMember.UpdatedAt)
194+
assert.NotEmpty(t, orgMem.OrganizationMember.UserID)
195+
assert.NotEmpty(t, orgMem.OrganizationMember.Roles)
167196
})
168197
rtr.ServeHTTP(rw, r)
169198
res := rw.Result()

coderd/users.go

+3-3
Original file line numberDiff line numberDiff line change
@@ -1271,13 +1271,13 @@ func userOrganizationIDs(ctx context.Context, api *API, user database.User) ([]u
12711271
return member.OrganizationIDs, nil
12721272
}
12731273

1274-
func usernameWithID(id uuid.UUID, users []database.User) (string, bool) {
1274+
func userByID(id uuid.UUID, users []database.User) (database.User, bool) {
12751275
for _, user := range users {
12761276
if id == user.ID {
1277-
return user.Username, true
1277+
return user, true
12781278
}
12791279
}
1280-
return "", false
1280+
return database.User{}, false
12811281
}
12821282

12831283
func convertAPIKey(k database.APIKey) codersdk.APIKey {

coderd/workspacebuilds.go

+35-30
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ func (api *API) workspaceBuild(rw http.ResponseWriter, r *http.Request) {
6969
})
7070
return
7171
}
72-
ownerName, ok := usernameWithID(workspace.OwnerID, data.users)
72+
owner, ok := userByID(workspace.OwnerID, data.users)
7373
if !ok {
7474
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
7575
Message: "Internal error converting workspace build.",
@@ -82,7 +82,8 @@ func (api *API) workspaceBuild(rw http.ResponseWriter, r *http.Request) {
8282
workspaceBuild,
8383
workspace,
8484
data.jobs[0],
85-
ownerName,
85+
owner.Username,
86+
owner.AvatarURL,
8687
data.resources,
8788
data.metadata,
8889
data.agents,
@@ -283,7 +284,7 @@ func (api *API) workspaceBuildByBuildNumber(rw http.ResponseWriter, r *http.Requ
283284
})
284285
return
285286
}
286-
ownerName, ok := usernameWithID(workspace.OwnerID, data.users)
287+
owner, ok := userByID(workspace.OwnerID, data.users)
287288
if !ok {
288289
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
289290
Message: "Internal error converting workspace build.",
@@ -296,7 +297,8 @@ func (api *API) workspaceBuildByBuildNumber(rw http.ResponseWriter, r *http.Requ
296297
workspaceBuild,
297298
workspace,
298299
data.jobs[0],
299-
ownerName,
300+
owner.Username,
301+
owner.AvatarURL,
300302
data.resources,
301303
data.metadata,
302304
data.agents,
@@ -416,7 +418,7 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
416418
})
417419
return
418420
}
419-
ownerName, exists := usernameWithID(workspace.OwnerID, users)
421+
owner, exists := userByID(workspace.OwnerID, users)
420422
if !exists {
421423
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
422424
Message: "Internal error converting workspace build.",
@@ -432,7 +434,8 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
432434
ProvisionerJob: *provisionerJob,
433435
QueuePosition: 0,
434436
},
435-
ownerName,
437+
owner.Username,
438+
owner.AvatarURL,
436439
[]database.WorkspaceResource{},
437440
[]database.WorkspaceResourceMetadatum{},
438441
[]database.WorkspaceAgent{},
@@ -833,7 +836,7 @@ func (api *API) convertWorkspaceBuilds(
833836
if !exists {
834837
return nil, xerrors.New("template version not found")
835838
}
836-
ownerName, exists := usernameWithID(workspace.OwnerID, users)
839+
owner, exists := userByID(workspace.OwnerID, users)
837840
if !exists {
838841
return nil, xerrors.Errorf("owner not found for workspace: %q", workspace.Name)
839842
}
@@ -842,7 +845,8 @@ func (api *API) convertWorkspaceBuilds(
842845
build,
843846
workspace,
844847
job,
845-
ownerName,
848+
owner.Username,
849+
owner.AvatarURL,
846850
workspaceResources,
847851
resourceMetadata,
848852
resourceAgents,
@@ -865,7 +869,7 @@ func (api *API) convertWorkspaceBuild(
865869
build database.WorkspaceBuild,
866870
workspace database.Workspace,
867871
job database.GetProvisionerJobsByIDsWithQueuePositionRow,
868-
ownerName string,
872+
username, avatarURL string,
869873
workspaceResources []database.WorkspaceResource,
870874
resourceMetadata []database.WorkspaceResourceMetadatum,
871875
resourceAgents []database.WorkspaceAgent,
@@ -909,7 +913,7 @@ func (api *API) convertWorkspaceBuild(
909913
scripts := scriptsByAgentID[agent.ID]
910914
logSources := logSourcesByAgentID[agent.ID]
911915
apiAgent, err := db2sdk.WorkspaceAgent(
912-
api.DERPMap(), *api.TailnetCoordinator.Load(), agent, db2sdk.Apps(apps, agent, ownerName, workspace), convertScripts(scripts), convertLogSources(logSources), api.AgentInactiveDisconnectTimeout,
916+
api.DERPMap(), *api.TailnetCoordinator.Load(), agent, db2sdk.Apps(apps, agent, username, workspace), convertScripts(scripts), convertLogSources(logSources), api.AgentInactiveDisconnectTimeout,
913917
api.DeploymentValues.AgentFallbackTroubleshootingURL.String(),
914918
)
915919
if err != nil {
@@ -923,26 +927,27 @@ func (api *API) convertWorkspaceBuild(
923927
apiJob := convertProvisionerJob(job)
924928
transition := codersdk.WorkspaceTransition(build.Transition)
925929
return codersdk.WorkspaceBuild{
926-
ID: build.ID,
927-
CreatedAt: build.CreatedAt,
928-
UpdatedAt: build.UpdatedAt,
929-
WorkspaceOwnerID: workspace.OwnerID,
930-
WorkspaceOwnerName: ownerName,
931-
WorkspaceID: build.WorkspaceID,
932-
WorkspaceName: workspace.Name,
933-
TemplateVersionID: build.TemplateVersionID,
934-
TemplateVersionName: templateVersion.Name,
935-
BuildNumber: build.BuildNumber,
936-
Transition: transition,
937-
InitiatorID: build.InitiatorID,
938-
InitiatorUsername: build.InitiatorByUsername,
939-
Job: apiJob,
940-
Deadline: codersdk.NewNullTime(build.Deadline, !build.Deadline.IsZero()),
941-
MaxDeadline: codersdk.NewNullTime(build.MaxDeadline, !build.MaxDeadline.IsZero()),
942-
Reason: codersdk.BuildReason(build.Reason),
943-
Resources: apiResources,
944-
Status: convertWorkspaceStatus(apiJob.Status, transition),
945-
DailyCost: build.DailyCost,
930+
ID: build.ID,
931+
CreatedAt: build.CreatedAt,
932+
UpdatedAt: build.UpdatedAt,
933+
WorkspaceOwnerID: workspace.OwnerID,
934+
WorkspaceOwnerName: username,
935+
WorkspaceOwnerAvatarURL: avatarURL,
936+
WorkspaceID: build.WorkspaceID,
937+
WorkspaceName: workspace.Name,
938+
TemplateVersionID: build.TemplateVersionID,
939+
TemplateVersionName: templateVersion.Name,
940+
BuildNumber: build.BuildNumber,
941+
Transition: transition,
942+
InitiatorID: build.InitiatorID,
943+
InitiatorUsername: build.InitiatorByUsername,
944+
Job: apiJob,
945+
Deadline: codersdk.NewNullTime(build.Deadline, !build.Deadline.IsZero()),
946+
MaxDeadline: codersdk.NewNullTime(build.MaxDeadline, !build.MaxDeadline.IsZero()),
947+
Reason: codersdk.BuildReason(build.Reason),
948+
Resources: apiResources,
949+
Status: convertWorkspaceStatus(apiJob.Status, transition),
950+
DailyCost: build.DailyCost,
946951
}, nil
947952
}
948953

coderd/workspacebuilds_test.go

+17-1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/coder/coder/v2/coderd/audit"
2222
"github.com/coder/coder/v2/coderd/coderdtest"
2323
"github.com/coder/coder/v2/coderd/database"
24+
"github.com/coder/coder/v2/coderd/database/dbauthz"
2425
"github.com/coder/coder/v2/coderd/database/dbtime"
2526
"github.com/coder/coder/v2/coderd/rbac"
2627
"github.com/coder/coder/v2/codersdk"
@@ -37,12 +38,23 @@ func TestWorkspaceBuild(t *testing.T) {
3738
propagation.Baggage{},
3839
),
3940
)
41+
ctx := testutil.Context(t, testutil.WaitShort)
4042
auditor := audit.NewMock()
41-
client := coderdtest.New(t, &coderdtest.Options{
43+
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
4244
IncludeProvisionerDaemon: true,
4345
Auditor: auditor,
4446
})
4547
user := coderdtest.CreateFirstUser(t, client)
48+
//nolint:gocritic // testing
49+
up, err := db.UpdateUserProfile(dbauthz.AsSystemRestricted(ctx), database.UpdateUserProfileParams{
50+
ID: user.UserID,
51+
Email: coderdtest.FirstUserParams.Email,
52+
Username: coderdtest.FirstUserParams.Username,
53+
Name: "Admin",
54+
AvatarURL: client.URL.String(),
55+
UpdatedAt: dbtime.Now(),
56+
})
57+
require.NoError(t, err)
4658
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
4759
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
4860
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
@@ -57,6 +69,10 @@ func TestWorkspaceBuild(t *testing.T) {
5769
assert.Equal(t, logs[0].Ip.IPNet.IP.String(), "127.0.0.1") &&
5870
assert.Equal(t, logs[1].Ip.IPNet.IP.String(), "127.0.0.1")
5971
}, testutil.WaitShort, testutil.IntervalFast)
72+
wb, err := client.WorkspaceBuild(testutil.Context(t, testutil.WaitShort), workspace.LatestBuild.ID)
73+
require.NoError(t, err)
74+
require.Equal(t, up.Username, wb.WorkspaceOwnerName)
75+
require.Equal(t, up.AvatarURL, wb.WorkspaceOwnerAvatarURL)
6076
}
6177

6278
func TestWorkspaceBuildByBuildNumber(t *testing.T) {

0 commit comments

Comments
 (0)