Skip to content

Commit cb28966

Browse files
committed
Support adding team_members by email address
1 parent 91135cb commit cb28966

File tree

7 files changed

+91
-27
lines changed

7 files changed

+91
-27
lines changed

client/team_member.go

+12-3
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,14 @@ type TeamMemberInviteRequest struct {
2121
TeamID string `json:"-"`
2222
}
2323

24-
func (c *Client) InviteTeamMember(ctx context.Context, request TeamMemberInviteRequest) error {
24+
type TeamMemberInviteResponse struct {
25+
UserID string `json:"uid"`
26+
Email string `json:"email"`
27+
Username string `json:"username"`
28+
Role string `json:"role"`
29+
}
30+
31+
func (c *Client) InviteTeamMember(ctx context.Context, request TeamMemberInviteRequest) (TeamMemberInviteResponse, error) {
2532
url := fmt.Sprintf("%s/v1/teams/%s/members", c.baseURL, request.TeamID)
2633
tflog.Info(ctx, "inviting user", map[string]any{
2734
"url": url,
@@ -30,13 +37,14 @@ func (c *Client) InviteTeamMember(ctx context.Context, request TeamMemberInviteR
3037
"role": request.Role,
3138
})
3239

40+
var res TeamMemberInviteResponse
3341
err := c.doRequest(clientRequest{
3442
ctx: ctx,
3543
method: "POST",
3644
url: url,
3745
body: string(mustMarshal(request)),
38-
}, nil)
39-
return err
46+
}, &res)
47+
return res, err
4048
}
4149

4250
type TeamMemberRemoveRequest struct {
@@ -93,6 +101,7 @@ type TeamMember struct {
93101
Confirmed bool `json:"confirmed"`
94102
Role string `json:"role"`
95103
UserID string `json:"uid"`
104+
Email string `json:"email"`
96105
Projects []ProjectRole `json:"projects"`
97106
AccessGroups []struct {
98107
ID string `json:"id"`

docs/resources/team_member.md

+9-2
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,17 @@ Provider a resource for managing a team member.
1313
## Example Usage
1414

1515
```terraform
16-
resource "vercel_team_member" "example" {
16+
resource "vercel_team_member" "by_user_id" {
1717
team_id = "team_xxxxxxxxxxxxxxxxxxxxxxxx"
1818
user_id = "uuuuuuuuuuuuuuuuuuuuuuuuuu"
1919
role = "MEMBER"
2020
}
21+
22+
resource "vercel_team_member" "by_email" {
23+
team_id = "team_xxxxxxxxxxxxxxxxxxxxxxxx"
24+
25+
role = "MEMBER"
26+
}
2127
```
2228

2329
<!-- schema generated by tfplugindocs -->
@@ -27,12 +33,13 @@ resource "vercel_team_member" "example" {
2733

2834
- `role` (String) The role that the user should have in the project. One of 'MEMBER', 'OWNER', 'VIEWER', 'DEVELOPER', 'BILLING' or 'CONTRIBUTOR'. Depending on your Team's plan, some of these roles may be unavailable.
2935
- `team_id` (String) The ID of the existing Vercel Team.
30-
- `user_id` (String) The ID of the user to add to the team.
3136

3237
### Optional
3338

3439
- `access_groups` (Set of String) If access groups are enabled on the team, and the user is a CONTRIBUTOR, `projects`, `access_groups` or both must be specified. A set of access groups IDs that the user should be granted access to.
40+
- `email` (String) The email of the user to add to the team. Must specify one of user_id or email.
3541
- `projects` (Attributes Set) If access groups are enabled on the team, and the user is a CONTRIBUTOR, `projects`, `access_groups` or both must be specified. A set of projects that the user should be granted access to, along with their role in each project. (see [below for nested schema](#nestedatt--projects))
42+
- `user_id` (String) The ID of the user to add to the team. Must specify one of user_id or email.
3643

3744
<a id="nestedatt--projects"></a>
3845
### Nested Schema for `projects`
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1-
resource "vercel_team_member" "example" {
1+
resource "vercel_team_member" "by_user_id" {
22
team_id = "team_xxxxxxxxxxxxxxxxxxxxxxxx"
33
user_id = "uuuuuuuuuuuuuuuuuuuuuuuuuu"
44
role = "MEMBER"
55
}
6+
7+
resource "vercel_team_member" "by_email" {
8+
team_id = "team_xxxxxxxxxxxxxxxxxxxxxxxx"
9+
10+
role = "MEMBER"
11+
}

vercel/data_source_team_member.go

+2
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ func (d *teamMemberDataSource) Schema(_ context.Context, req datasource.SchemaRe
104104

105105
type TeamMemberWithID struct {
106106
UserID types.String `tfsdk:"user_id"`
107+
Email types.String `tfsdk:"email"`
107108
TeamID types.String `tfsdk:"team_id"`
108109
Role types.String `tfsdk:"role"`
109110
Projects types.Set `tfsdk:"projects"`
@@ -133,6 +134,7 @@ func (d *teamMemberDataSource) Read(ctx context.Context, req datasource.ReadRequ
133134
diags = resp.State.Set(ctx, TeamMemberWithID{
134135
UserID: teamMember.UserID,
135136
TeamID: teamMember.TeamID,
137+
Email: teamMember.Email,
136138
Role: teamMember.Role,
137139
Projects: teamMember.Projects,
138140
AccessGroups: teamMember.AccessGroups,

vercel/provider_test.go

+4
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ func testAdditionalUser() string {
7373
return os.Getenv("VERCEL_TERRAFORM_TESTING_ADDITIONAL_USER")
7474
}
7575

76+
func testAdditionalUserEmail() string {
77+
return os.Getenv("VERCEL_TERRAFORM_TESTING_ADDITIONAL_USER_EMAIL")
78+
}
79+
7680
func testExistingIntegration() string {
7781
return os.Getenv("VERCEL_TERRAFORM_TESTING_EXISTING_INTEGRATION")
7882
}

vercel/resource_team_config.go

+4-4
Original file line numberDiff line numberDiff line change
@@ -130,10 +130,10 @@ func (r *teamConfigResource) Schema(_ context.Context, req resource.SchemaReques
130130
ElementType: types.StringType,
131131
Validators: []validator.Map{
132132
// Validate only this attribute or roles is configured.
133-
mapvalidator.ExactlyOneOf(path.Expressions{
133+
mapvalidator.ExactlyOneOf(
134134
path.MatchRoot("saml.roles"),
135135
path.MatchRoot("saml.access_group_id"),
136-
}...),
136+
),
137137
},
138138
},
139139
"access_group_id": schema.StringAttribute{
@@ -142,10 +142,10 @@ func (r *teamConfigResource) Schema(_ context.Context, req resource.SchemaReques
142142
Validators: []validator.String{
143143
stringvalidator.RegexMatches(regexp.MustCompile("^ag_[A-z0-9_ -]+$"), "Access group ID must be a valid access group"),
144144
// Validate only this attribute or roles is configured.
145-
stringvalidator.ExactlyOneOf(path.Expressions{
145+
stringvalidator.ExactlyOneOf(
146146
path.MatchRoot("saml.roles"),
147147
path.MatchRoot("saml.access_group_id"),
148-
}...),
148+
),
149149
},
150150
},
151151
},

vercel/resource_team_member.go

+53-17
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
1111
"github.com/hashicorp/terraform-plugin-framework/attr"
1212
"github.com/hashicorp/terraform-plugin-framework/diag"
13+
"github.com/hashicorp/terraform-plugin-framework/path"
1314
"github.com/hashicorp/terraform-plugin-framework/resource"
1415
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
1516
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
@@ -71,11 +72,33 @@ func (r *teamMemberResource) Schema(_ context.Context, req resource.SchemaReques
7172
},
7273
},
7374
"user_id": schema.StringAttribute{
74-
Description: "The ID of the user to add to the team.",
75-
Required: true,
75+
Description: "The ID of the user to add to the team. Must specify one of user_id or email.",
76+
Optional: true,
77+
Computed: true,
7678
PlanModifiers: []planmodifier.String{
7779
stringplanmodifier.UseStateForUnknown(),
78-
stringplanmodifier.RequiresReplace(),
80+
stringplanmodifier.RequiresReplaceIfConfigured(),
81+
},
82+
Validators: []validator.String{
83+
stringvalidator.ExactlyOneOf(
84+
path.MatchRoot("user_id"),
85+
path.MatchRoot("email"),
86+
),
87+
},
88+
},
89+
"email": schema.StringAttribute{
90+
Description: "The email of the user to add to the team. Must specify one of user_id or email.",
91+
Optional: true,
92+
Computed: true,
93+
PlanModifiers: []planmodifier.String{
94+
stringplanmodifier.UseStateForUnknown(),
95+
stringplanmodifier.RequiresReplaceIfConfigured(),
96+
},
97+
Validators: []validator.String{
98+
stringvalidator.ExactlyOneOf(
99+
path.MatchRoot("user_id"),
100+
path.MatchRoot("email"),
101+
),
79102
},
80103
},
81104
"role": schema.StringAttribute{
@@ -126,6 +149,7 @@ func (r *teamMemberResource) Schema(_ context.Context, req resource.SchemaReques
126149

127150
type TeamMember struct {
128151
UserID types.String `tfsdk:"user_id"`
152+
Email types.String `tfsdk:"email"`
129153
TeamID types.String `tfsdk:"team_id"`
130154
Role types.String `tfsdk:"role"`
131155
Projects types.Set `tfsdk:"projects"`
@@ -177,6 +201,7 @@ func (t TeamMember) toInviteTeamMemberRequest(ctx context.Context) (client.TeamM
177201
return client.TeamMemberInviteRequest{
178202
TeamID: t.TeamID.ValueString(),
179203
UserID: t.UserID.ValueString(),
204+
Email: t.Email.ValueString(),
180205
Role: t.Role.ValueString(),
181206
Projects: projects,
182207
AccessGroups: accessGroups,
@@ -269,6 +294,7 @@ func convertResponseToTeamMember(response client.TeamMember, teamID types.String
269294

270295
return TeamMember{
271296
UserID: types.StringValue(response.UserID),
297+
Email: types.StringValue(response.Email),
272298
TeamID: teamID,
273299
Role: types.StringValue(response.Role),
274300
Projects: projects,
@@ -289,7 +315,7 @@ func (r *teamMemberResource) Create(ctx context.Context, req resource.CreateRequ
289315
resp.Diagnostics.Append(diags...)
290316
return
291317
}
292-
err := r.client.InviteTeamMember(ctx, request)
318+
res, err := r.client.InviteTeamMember(ctx, request)
293319
if err != nil {
294320
resp.Diagnostics.AddError(
295321
"Error inviting Team Member",
@@ -303,21 +329,30 @@ func (r *teamMemberResource) Create(ctx context.Context, req resource.CreateRequ
303329
"user_id": plan.UserID.ValueString(),
304330
})
305331

306-
projects := types.SetNull(projectsElemType)
307-
if !plan.Projects.IsUnknown() && !plan.Projects.IsNull() {
308-
projects = plan.Projects
332+
response, err := r.client.GetTeamMember(ctx, client.GetTeamMemberRequest{
333+
TeamID: plan.TeamID.ValueString(),
334+
UserID: res.UserID,
335+
})
336+
if err != nil {
337+
resp.Diagnostics.AddError(
338+
"Error reading Team Member",
339+
"Could not read Team Member, unexpected error: "+err.Error(),
340+
)
309341
}
310-
ags := types.SetNull(types.StringType)
311-
if !plan.AccessGroups.IsUnknown() && !plan.AccessGroups.IsNull() {
312-
ags = plan.AccessGroups
342+
teamMember := convertResponseToTeamMember(response, plan.TeamID)
343+
if !response.Confirmed {
344+
// The API doesn't return the projects or access groups for unconfirmed members, so we have to
345+
// manually set these fields to whatever was in the plan.
346+
teamMember.Projects = types.SetNull(projectsElemType)
347+
if !plan.Projects.IsUnknown() && !plan.Projects.IsNull() {
348+
teamMember.Projects = plan.Projects
349+
}
350+
teamMember.AccessGroups = types.SetNull(types.StringType)
351+
if !plan.AccessGroups.IsUnknown() && !plan.AccessGroups.IsNull() {
352+
teamMember.AccessGroups = plan.AccessGroups
353+
}
313354
}
314-
diags = resp.State.Set(ctx, TeamMember{
315-
TeamID: plan.TeamID,
316-
UserID: plan.UserID,
317-
Role: plan.Role,
318-
Projects: projects,
319-
AccessGroups: ags,
320-
})
355+
diags = resp.State.Set(ctx, teamMember)
321356
resp.Diagnostics.Append(diags...)
322357
sleepInTests()
323358
}
@@ -414,6 +449,7 @@ func (r *teamMemberResource) Update(ctx context.Context, req resource.UpdateRequ
414449
diags = resp.State.Set(ctx, TeamMember{
415450
TeamID: plan.TeamID,
416451
UserID: plan.UserID,
452+
Email: plan.Email,
417453
Role: plan.Role,
418454
Projects: projects,
419455
AccessGroups: ags,

0 commit comments

Comments
 (0)