diff --git a/client/project_member.go b/client/project_member.go
new file mode 100644
index 00000000..6289ed0c
--- /dev/null
+++ b/client/project_member.go
@@ -0,0 +1,140 @@
+package client
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/hashicorp/terraform-plugin-log/tflog"
+)
+
+type ProjectMember struct {
+ UserID string `json:"uid,omitempty"`
+ Username string `json:"username,omitempty"`
+ Email string `json:"email,omitempty"`
+ Role string `json:"role"`
+}
+
+type AddProjectMembersRequest struct {
+ ProjectID string `json:"-"`
+ TeamID string `json:"-"`
+ Members []ProjectMember `json:"members"`
+}
+
+func (c *Client) AddProjectMembers(ctx context.Context, request AddProjectMembersRequest) error {
+ url := fmt.Sprintf("%s/v1/projects/%s/members/batch", c.baseURL, request.ProjectID)
+ if c.teamID(request.TeamID) != "" {
+ url = fmt.Sprintf("%s?teamId=%s", url, c.teamID(request.TeamID))
+ }
+ tflog.Info(ctx, "adding project members", map[string]interface{}{
+ "url": url,
+ })
+ err := c.doRequest(clientRequest{
+ ctx: ctx,
+ method: "POST",
+ url: url,
+ body: string(mustMarshal(request)),
+ }, nil)
+ if err != nil {
+ tflog.Error(ctx, "error adding project members", map[string]interface{}{
+ "url": url,
+ "members": request.Members,
+ })
+ }
+ return err
+}
+
+type RemoveProjectMembersRequest struct {
+ ProjectID string `json:"-"`
+ TeamID string `json:"-"`
+ Members []string `json:"members"`
+}
+
+func (c *Client) RemoveProjectMembers(ctx context.Context, request RemoveProjectMembersRequest) error {
+ url := fmt.Sprintf("%s/v1/projects/%s/members/batch", c.baseURL, request.ProjectID)
+ if c.teamID(request.TeamID) != "" {
+ url = fmt.Sprintf("%s?teamId=%s", url, c.teamID(request.TeamID))
+ }
+ tflog.Info(ctx, "removing project members", map[string]interface{}{
+ "url": url,
+ })
+ err := c.doRequest(clientRequest{
+ ctx: ctx,
+ method: "DELETE",
+ url: url,
+ body: string(mustMarshal(request)),
+ }, nil)
+ if err != nil {
+ tflog.Error(ctx, "error removing project members", map[string]interface{}{
+ "url": url,
+ "members": request.Members,
+ })
+ }
+ return err
+}
+
+type UpdateProjectMemberRequest struct {
+ UserID string `json:"uid,omitempty"`
+ Role string `json:"role"`
+}
+
+type UpdateProjectMembersRequest struct {
+ ProjectID string `json:"-"`
+ TeamID string `json:"-"`
+ Members []UpdateProjectMemberRequest `json:"members"`
+}
+
+func (c *Client) UpdateProjectMembers(ctx context.Context, request UpdateProjectMembersRequest) error {
+ url := fmt.Sprintf("%s/v1/projects/%s/members", c.baseURL, request.ProjectID)
+ if c.teamID(request.TeamID) != "" {
+ url = fmt.Sprintf("%s?teamId=%s", url, c.teamID(request.TeamID))
+ }
+ payload := string(mustMarshal(request))
+ tflog.Info(ctx, "updating project members", map[string]interface{}{
+ "url": url,
+ "payload": payload,
+ })
+ err := c.doRequest(clientRequest{
+ ctx: ctx,
+ method: "PATCH",
+ url: url,
+ body: payload,
+ }, nil)
+ if err != nil {
+ tflog.Error(ctx, "error updating project members", map[string]interface{}{
+ "url": url,
+ "members": request.Members,
+ })
+ }
+ return err
+}
+
+type GetProjectMembersRequest struct {
+ ProjectID string `json:"-"`
+ TeamID string `json:"-"`
+}
+
+func (c *Client) ListProjectMembers(ctx context.Context, request GetProjectMembersRequest) ([]ProjectMember, error) {
+ url := fmt.Sprintf("%s/v1/projects/%s/members", c.baseURL, request.ProjectID)
+ if c.teamID(request.TeamID) != "" {
+ url = fmt.Sprintf("%s?teamId=%s&limit=100", url, c.teamID(request.TeamID))
+ }
+ tflog.Info(ctx, "listing project members", map[string]interface{}{
+ "url": url,
+ })
+
+ var resp struct {
+ Members []ProjectMember `json:"members"`
+ }
+ err := c.doRequest(clientRequest{
+ ctx: ctx,
+ method: "GET",
+ url: url,
+ body: string(mustMarshal(request)),
+ }, &resp)
+ if err != nil {
+ tflog.Error(ctx, "error getting project members", map[string]interface{}{
+ "url": url,
+ })
+ }
+ return resp.Members, err
+}
diff --git a/docs/data-sources/project_members.md b/docs/data-sources/project_members.md
new file mode 100644
index 00000000..d6c727f5
--- /dev/null
+++ b/docs/data-sources/project_members.md
@@ -0,0 +1,48 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "vercel_project_members Data Source - terraform-provider-vercel"
+subcategory: ""
+description: |-
+ Retrieves members and their roles for a Vercel Project.
+---
+
+# vercel_project_members (Data Source)
+
+Retrieves members and their roles for a Vercel Project.
+
+## Example Usage
+
+```terraform
+data "vercel_project" "example" {
+ name = "example-with-members"
+}
+
+data "vercel_project_members" "example" {
+ project_id = data.vercel_project.example.id
+}
+```
+
+
+## Schema
+
+### Required
+
+- `project_id` (String) The ID of the Vercel Project.
+
+### Optional
+
+- `team_id` (String) The team ID to which the project belongs. Required when accessing a team project if a default team has not been set in the provider.
+
+### Read-Only
+
+- `members` (Attributes Set) The set of members in this project. (see [below for nested schema](#nestedatt--members))
+
+
+### Nested Schema for `members`
+
+Read-Only:
+
+- `email` (String) The email of the user.
+- `role` (String) The role of the user in the project. One of 'MEMBER', 'PROJECT_DEVELOPER', or 'PROJECT_VIEWER'.
+- `user_id` (String) The ID of the user.
+- `username` (String) The username of the user.
diff --git a/docs/resources/project_members.md b/docs/resources/project_members.md
new file mode 100644
index 00000000..f248f772
--- /dev/null
+++ b/docs/resources/project_members.md
@@ -0,0 +1,63 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "vercel_project_members Resource - terraform-provider-vercel"
+subcategory: ""
+description: |-
+ Manages members and their roles for a Vercel Project.
+ ~> Note that this resource does not manage the complete set of members for a project, only the members that
+ are explicitly configured here. This is deliberately done to allow granular additions.
+ This, however, means config drift will not be detected for members that are added or removed outside of terraform.
+---
+
+# vercel_project_members (Resource)
+
+Manages members and their roles for a Vercel Project.
+
+~> Note that this resource does not manage the complete set of members for a project, only the members that
+are explicitly configured here. This is deliberately done to allow granular additions.
+This, however, means config drift will not be detected for members that are added or removed outside of terraform.
+
+## Example Usage
+
+```terraform
+resource "vercel_project" "example" {
+ name = "example-with-members"
+}
+
+resource "vercel_project_members" "example" {
+ project_id = vercel_project.example.id
+
+ members = [{
+ email = "user@example.com"
+ role = "PROJECT_VIEWER"
+ }, {
+ username = "some-example-user"
+ role = "PROJECT_DEVELOPER"
+ }]
+}
+```
+
+
+## Schema
+
+### Required
+
+- `members` (Attributes Set) The set of members to manage for this project. (see [below for nested schema](#nestedatt--members))
+- `project_id` (String) The ID of the existing Vercel Project.
+
+### Optional
+
+- `team_id` (String) The team ID to add the project to. Required when configuring a team resource if a default team has not been set in the provider.
+
+
+### Nested Schema for `members`
+
+Required:
+
+- `role` (String) The role that the user should have in the project. One of 'MEMBER', 'PROJECT_DEVELOPER', or 'PROJECT_VIEWER'.
+
+Optional:
+
+- `email` (String) The email of the user to add to the project. Exactly one of `user_id`, `email`, or `username` must be specified.
+- `user_id` (String) The ID of the user to add to the project. Exactly one of `user_id`, `email`, or `username` must be specified.
+- `username` (String) The username of the user to add to the project. Exactly one of `user_id`, `email`, or `username` must be specified.
diff --git a/examples/data-sources/vercel_project_members/data-source.tf b/examples/data-sources/vercel_project_members/data-source.tf
new file mode 100644
index 00000000..1344bb0e
--- /dev/null
+++ b/examples/data-sources/vercel_project_members/data-source.tf
@@ -0,0 +1,7 @@
+data "vercel_project" "example" {
+ name = "example-with-members"
+}
+
+data "vercel_project_members" "example" {
+ project_id = data.vercel_project.example.id
+}
diff --git a/examples/resources/vercel_project_members/resource.tf b/examples/resources/vercel_project_members/resource.tf
new file mode 100644
index 00000000..56a70a01
--- /dev/null
+++ b/examples/resources/vercel_project_members/resource.tf
@@ -0,0 +1,15 @@
+resource "vercel_project" "example" {
+ name = "example-with-members"
+}
+
+resource "vercel_project_members" "example" {
+ project_id = vercel_project.example.id
+
+ members = [{
+ email = "user@example.com"
+ role = "PROJECT_VIEWER"
+ }, {
+ username = "some-example-user"
+ role = "PROJECT_DEVELOPER"
+ }]
+}
diff --git a/go.mod b/go.mod
index 38ba2695..af6d1093 100644
--- a/go.mod
+++ b/go.mod
@@ -45,7 +45,6 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/oklog/run v1.1.0 // indirect
- github.com/vercel/terraform-provider-vercel v1.14.1
github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
diff --git a/go.sum b/go.sum
index f4517c11..09e5b437 100644
--- a/go.sum
+++ b/go.sum
@@ -140,8 +140,6 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-github.com/vercel/terraform-provider-vercel v1.14.1 h1:ghAjFkMMzka4XuoBYdu1OXM/K7FQEj8wUd+xMPPOGrg=
-github.com/vercel/terraform-provider-vercel v1.14.1/go.mod h1:AdFCiUD0XP8XOi6tnhaCh7I0vyq2TAPmI+GcIp3+7SI=
github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
diff --git a/vercel/data_source_project_members.go b/vercel/data_source_project_members.go
new file mode 100644
index 00000000..70c55efb
--- /dev/null
+++ b/vercel/data_source_project_members.go
@@ -0,0 +1,128 @@
+package vercel
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/hashicorp/terraform-plugin-framework/attr"
+ "github.com/hashicorp/terraform-plugin-framework/datasource"
+ "github.com/hashicorp/terraform-plugin-framework/datasource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+ "github.com/vercel/terraform-provider-vercel/v2/client"
+)
+
+var (
+ _ datasource.DataSource = &projectMembersDataSource{}
+ _ datasource.DataSourceWithConfigure = &projectMembersDataSource{}
+)
+
+func newProjectMembersDataSource() datasource.DataSource {
+ return &projectMembersDataSource{}
+}
+
+type projectMembersDataSource struct {
+ client *client.Client
+}
+
+func (d *projectMembersDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
+ resp.TypeName = req.ProviderTypeName + "_project_members"
+}
+
+func (d *projectMembersDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
+ if req.ProviderData == nil {
+ return
+ }
+
+ client, ok := req.ProviderData.(*client.Client)
+ if !ok {
+ resp.Diagnostics.AddError(
+ "Unexpected Data Source Configure Type",
+ fmt.Sprintf("Expected *client.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData),
+ )
+ return
+ }
+
+ d.client = client
+}
+
+func (d *projectMembersDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
+ resp.Schema = schema.Schema{
+ Description: "Retrieves members and their roles for a Vercel Project.",
+ Attributes: map[string]schema.Attribute{
+ "team_id": schema.StringAttribute{
+ Optional: true,
+ Description: "The team ID to which the project belongs. Required when accessing a team project if a default team has not been set in the provider.",
+ },
+ "project_id": schema.StringAttribute{
+ Required: true,
+ Description: "The ID of the Vercel Project.",
+ },
+ "members": schema.SetNestedAttribute{
+ Computed: true,
+ Description: "The set of members in this project.",
+ NestedObject: schema.NestedAttributeObject{
+ Attributes: map[string]schema.Attribute{
+ "user_id": schema.StringAttribute{
+ Computed: true,
+ Description: "The ID of the user.",
+ },
+ "email": schema.StringAttribute{
+ Computed: true,
+ Description: "The email of the user.",
+ },
+ "username": schema.StringAttribute{
+ Computed: true,
+ Description: "The username of the user.",
+ },
+ "role": schema.StringAttribute{
+ Computed: true,
+ Description: "The role of the user in the project. One of 'MEMBER', 'PROJECT_DEVELOPER', or 'PROJECT_VIEWER'.",
+ },
+ },
+ },
+ },
+ },
+ }
+}
+
+type ProjectMembersDataSourceModel struct {
+ TeamID types.String `tfsdk:"team_id"`
+ ProjectID types.String `tfsdk:"project_id"`
+ Members types.Set `tfsdk:"members"`
+}
+
+func (d *projectMembersDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
+ var config ProjectMembersDataSourceModel
+ diags := req.Config.Get(ctx, &config)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ members, err := d.client.ListProjectMembers(ctx, client.GetProjectMembersRequest{
+ TeamID: config.TeamID.ValueString(),
+ ProjectID: config.ProjectID.ValueString(),
+ })
+ if err != nil {
+ resp.Diagnostics.AddError(
+ "Error reading Project Members",
+ fmt.Sprintf("Could not read Project Members, unexpected error: %s", err),
+ )
+ return
+ }
+
+ // Convert API response to model
+ var memberItems []attr.Value
+ for _, member := range members {
+ memberItems = append(memberItems, types.ObjectValueMust(memberAttrType.AttrTypes, map[string]attr.Value{
+ "user_id": types.StringValue(member.UserID),
+ "email": types.StringValue(member.Email),
+ "username": types.StringValue(member.Username),
+ "role": types.StringValue(member.Role),
+ }))
+ }
+
+ config.Members = types.SetValueMust(memberAttrType, memberItems)
+ diags = resp.State.Set(ctx, &config)
+ resp.Diagnostics.Append(diags...)
+}
diff --git a/vercel/data_source_project_members_test.go b/vercel/data_source_project_members_test.go
new file mode 100644
index 00000000..64aae87a
--- /dev/null
+++ b/vercel/data_source_project_members_test.go
@@ -0,0 +1,51 @@
+package vercel_test
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/hashicorp/terraform-plugin-testing/helper/acctest"
+ "github.com/hashicorp/terraform-plugin-testing/helper/resource"
+)
+
+func TestAcc_ProjectMembersDataSource(t *testing.T) {
+ projectSuffix := acctest.RandString(16)
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheck(t) },
+ ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
+ CheckDestroy: testAccProjectDestroy("vercel_project.test", testTeam()),
+ Steps: []resource.TestStep{
+ {
+ Config: testAccProjectMembersDataSourceConfig(projectSuffix),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttrSet("data.vercel_project_members.test", "project_id"),
+ resource.TestCheckResourceAttr("data.vercel_project_members.test", "members.#", "1"),
+ ),
+ },
+ },
+ })
+}
+
+func testAccProjectMembersDataSourceConfig(projectSuffix string) string {
+ return fmt.Sprintf(`
+resource "vercel_project" "test" {
+ name = "test-acc-project-members-%[1]s"
+ %[2]s
+}
+
+resource "vercel_project_members" "test" {
+ project_id = vercel_project.test.id
+ %[2]s
+
+ members = [{
+ email = "doug+test2@vercel.com"
+ role = "PROJECT_VIEWER"
+ }]
+}
+
+data "vercel_project_members" "test" {
+ project_id = vercel_project_members.test.project_id
+ %[2]s
+}
+`, projectSuffix, teamIDConfig())
+}
diff --git a/vercel/provider.go b/vercel/provider.go
index 25d166be..a0a54e17 100644
--- a/vercel/provider.go
+++ b/vercel/provider.go
@@ -66,6 +66,7 @@ func (p *vercelProvider) Resources(_ context.Context) []func() resource.Resource
newProjectDomainResource,
newProjectEnvironmentVariableResource,
newProjectEnvironmentVariablesResource,
+ newProjectMembersResource,
newProjectResource,
newSharedEnvironmentVariableResource,
newTeamConfigResource,
@@ -92,6 +93,7 @@ func (p *vercelProvider) DataSources(_ context.Context) []func() datasource.Data
newProjectDataSource,
newProjectDeploymentRetentionDataSource,
newProjectDirectoryDataSource,
+ newProjectMembersDataSource,
newSharedEnvironmentVariableDataSource,
newTeamConfigDataSource,
newTeamMemberDataSource,
diff --git a/vercel/resource_project.go b/vercel/resource_project.go
index 60cf2523..f21251b6 100644
--- a/vercel/resource_project.go
+++ b/vercel/resource_project.go
@@ -1217,28 +1217,15 @@ func convertResponseToProject(ctx context.Context, response client.ProjectRespon
}
}
- env = append(env, types.ObjectValueMust(
- map[string]attr.Type{
- "key": types.StringType,
- "value": types.StringType,
- "target": types.SetType{
- ElemType: types.StringType,
- },
- "git_branch": types.StringType,
- "id": types.StringType,
- "sensitive": types.BoolType,
- "comment": types.StringType,
- },
- map[string]attr.Value{
- "key": types.StringValue(e.Key),
- "value": value,
- "target": types.SetValueMust(types.StringType, target),
- "git_branch": types.StringPointerValue(e.GitBranch),
- "id": types.StringValue(e.ID),
- "sensitive": types.BoolValue(e.Type == "sensitive"),
- "comment": types.StringValue(e.Comment),
- },
- ))
+ env = append(env, types.ObjectValueMust(envVariableElemType.AttrTypes, map[string]attr.Value{
+ "key": types.StringValue(e.Key),
+ "value": value,
+ "target": types.SetValueMust(types.StringType, target),
+ "git_branch": types.StringPointerValue(e.GitBranch),
+ "id": types.StringValue(e.ID),
+ "sensitive": types.BoolValue(e.Type == "sensitive"),
+ "comment": types.StringValue(e.Comment),
+ }))
}
protectionBypassSecret := types.StringNull()
diff --git a/vercel/resource_project_members.go b/vercel/resource_project_members.go
new file mode 100644
index 00000000..6ad2d5ee
--- /dev/null
+++ b/vercel/resource_project_members.go
@@ -0,0 +1,528 @@
+package vercel
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
+ "github.com/hashicorp/terraform-plugin-framework/attr"
+ "github.com/hashicorp/terraform-plugin-framework/diag"
+ "github.com/hashicorp/terraform-plugin-framework/path"
+ "github.com/hashicorp/terraform-plugin-framework/resource"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/schema/validator"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+ "github.com/hashicorp/terraform-plugin-log/tflog"
+ "github.com/vercel/terraform-provider-vercel/v2/client"
+)
+
+var (
+ _ resource.Resource = &projectMembersResource{}
+ _ resource.ResourceWithConfigure = &projectMembersResource{}
+)
+
+func newProjectMembersResource() resource.Resource {
+ return &projectMembersResource{}
+}
+
+type projectMembersResource struct {
+ client *client.Client
+}
+
+func (r *projectMembersResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
+ resp.TypeName = req.ProviderTypeName + "_project_members"
+}
+
+func (r *projectMembersResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
+ if req.ProviderData == nil {
+ return
+ }
+
+ client, ok := req.ProviderData.(*client.Client)
+ if !ok {
+ resp.Diagnostics.AddError(
+ "Unexpected Resource Configure Type",
+ fmt.Sprintf("Expected *client.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData),
+ )
+ return
+ }
+
+ r.client = client
+}
+
+func (r *projectMembersResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
+ resp.Schema = schema.Schema{
+ Description: `
+Manages members and their roles for a Vercel Project.
+
+~> Note that this resource does not manage the complete set of members for a project, only the members that
+are explicitly configured here. This is deliberately done to allow granular additions.
+This, however, means config drift will not be detected for members that are added or removed outside of terraform.
+`,
+ Attributes: map[string]schema.Attribute{
+ "team_id": schema.StringAttribute{
+ Optional: true,
+ Computed: true,
+ PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplaceIfConfigured(), stringplanmodifier.UseStateForUnknown()},
+ Description: "The team ID to add the project to. Required when configuring a team resource if a default team has not been set in the provider.",
+ },
+ "project_id": schema.StringAttribute{
+ Description: "The ID of the existing Vercel Project.",
+ Required: true,
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.UseStateForUnknown(),
+ stringplanmodifier.RequiresReplace(),
+ },
+ },
+ "members": schema.SetNestedAttribute{
+ Description: "The set of members to manage for this project.",
+ Required: true,
+ NestedObject: schema.NestedAttributeObject{
+ Attributes: map[string]schema.Attribute{
+ "user_id": schema.StringAttribute{
+ Description: "The ID of the user to add to the project. Exactly one of `user_id`, `email`, or `username` must be specified.",
+ Optional: true,
+ Computed: true,
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.UseStateForUnknown(),
+ },
+ Validators: []validator.String{
+ stringvalidator.ExactlyOneOf(
+ path.MatchRelative().AtParent().AtName("email"),
+ path.MatchRelative().AtParent().AtName("username"),
+ ),
+ },
+ },
+ "email": schema.StringAttribute{
+ Description: "The email of the user to add to the project. Exactly one of `user_id`, `email`, or `username` must be specified.",
+ Optional: true,
+ Computed: true,
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.UseStateForUnknown(),
+ },
+ Validators: []validator.String{
+ stringvalidator.ExactlyOneOf(
+ path.MatchRelative().AtParent().AtName("user_id"),
+ path.MatchRelative().AtParent().AtName("username"),
+ ),
+ },
+ },
+ "username": schema.StringAttribute{
+ Description: "The username of the user to add to the project. Exactly one of `user_id`, `email`, or `username` must be specified.",
+ Optional: true,
+ Computed: true,
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.UseStateForUnknown(),
+ },
+ Validators: []validator.String{
+ stringvalidator.ExactlyOneOf(
+ path.MatchRelative().AtParent().AtName("user_id"),
+ path.MatchRelative().AtParent().AtName("email"),
+ ),
+ },
+ },
+ "role": schema.StringAttribute{
+ Description: "The role that the user should have in the project. One of 'MEMBER', 'PROJECT_DEVELOPER', or 'PROJECT_VIEWER'.",
+ Required: true,
+ Validators: []validator.String{
+ stringvalidator.OneOf("MEMBER", "PROJECT_DEVELOPER", "PROJECT_VIEWER"),
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+}
+
+type ProjectMembersModel struct {
+ TeamID types.String `tfsdk:"team_id"`
+ ProjectID types.String `tfsdk:"project_id"`
+ Members types.Set `tfsdk:"members"`
+}
+
+type ProjectMemberItem struct {
+ UserID types.String `tfsdk:"user_id"`
+ Email types.String `tfsdk:"email"`
+ Username types.String `tfsdk:"username"`
+ Role types.String `tfsdk:"role"`
+}
+
+func (m ProjectMembersModel) members(ctx context.Context) ([]ProjectMemberItem, diag.Diagnostics) {
+ var members []ProjectMemberItem
+ diags := m.Members.ElementsAs(ctx, &members, false)
+ return members, diags
+}
+
+var memberAttrType = types.ObjectType{
+ AttrTypes: map[string]attr.Type{
+ "user_id": types.StringType,
+ "email": types.StringType,
+ "username": types.StringType,
+ "role": types.StringType,
+ },
+}
+
+func (r *projectMembersResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
+ var plan ProjectMembersModel
+ diags := req.Plan.Get(ctx, &plan)
+ if diags.HasError() {
+ resp.Diagnostics.Append(diags...)
+ return
+ }
+
+ planMembers, diags := plan.members(ctx)
+ if diags.HasError() {
+ resp.Diagnostics.Append(diags...)
+ return
+ }
+ var requestMembers []client.ProjectMember
+ for _, m := range planMembers {
+ requestMembers = append(requestMembers, client.ProjectMember{
+ UserID: m.UserID.ValueString(),
+ Username: m.Username.ValueString(),
+ Email: m.Email.ValueString(),
+ Role: m.Role.ValueString(),
+ })
+ }
+
+ err := r.client.AddProjectMembers(ctx, client.AddProjectMembersRequest{
+ ProjectID: plan.ProjectID.ValueString(),
+ TeamID: plan.TeamID.ValueString(),
+ Members: requestMembers,
+ })
+ if err != nil {
+ resp.Diagnostics.AddError(
+ "Error adding Project Members",
+ fmt.Sprintf("Could not add Project Members, unexpected error: %s", err),
+ )
+ return
+ }
+
+ members, err := r.client.ListProjectMembers(ctx, client.GetProjectMembersRequest{
+ TeamID: plan.TeamID.ValueString(),
+ ProjectID: plan.ProjectID.ValueString(),
+ })
+ tflog.Trace(ctx, "read project members", map[string]interface{}{
+ "team_id": plan.TeamID.ValueString(),
+ "project_id": plan.ProjectID.ValueString(),
+ "members": members,
+ })
+ if client.NotFound(err) {
+ resp.State.RemoveResource(ctx)
+ }
+ if err != nil {
+ resp.Diagnostics.AddError(
+ "Error reading Project Members",
+ fmt.Sprintf("Could not read Project Members, unexpected error: %s", err),
+ )
+ return
+ }
+
+ // Convert API response to model
+ var memberItems []attr.Value
+ for _, member := range members {
+ if terraformHasMember(planMembers, member) {
+ memberItems = append(memberItems, types.ObjectValueMust(memberAttrType.AttrTypes, map[string]attr.Value{
+ "user_id": types.StringValue(member.UserID),
+ "email": types.StringValue(member.Email),
+ "username": types.StringValue(member.Username),
+ "role": types.StringValue(member.Role),
+ }))
+ }
+ }
+
+ plan.Members = types.SetValueMust(memberAttrType, memberItems)
+ diags = resp.State.Set(ctx, &plan)
+ resp.Diagnostics.Append(diags...)
+}
+
+// diffMembers compares the state and planned members to determine which members need to be added, removed, or updated
+func diffMembers(stateMembers, plannedMembers []ProjectMemberItem) (toAdd, toRemove, toUpdate []ProjectMemberItem) {
+ stateMap := map[string]ProjectMemberItem{}
+ plannedMap := map[string]ProjectMemberItem{}
+
+ for _, member := range stateMembers {
+ stateMap[member.UserID.ValueString()] = member
+ }
+
+ for _, member := range plannedMembers {
+ stateMember, inState := stateMap[member.UserID.ValueString()]
+ if member.UserID.IsUnknown() || member.Email.IsUnknown() || member.Username.IsUnknown() || !inState {
+ // Then the member hasn't been created yet, so add it.
+ toAdd = append(toAdd, member)
+ continue
+ }
+ if _, ok := stateMap[member.UserID.ValueString()]; !ok {
+ // Then the member hasn't been created yet, so add it.
+ toAdd = append(toAdd, member)
+ continue
+ }
+
+ // Add to planned, so we can reverse look up ones to remove later.
+ plannedMap[member.UserID.ValueString()] = member
+ if inState && stateMember.Role != member.Role {
+ toUpdate = append(toUpdate, member)
+ }
+ }
+
+ // Find members to remove (in state but not in plan)
+ for key, member := range stateMap {
+ if _, exists := plannedMap[key]; !exists {
+ toRemove = append(toRemove, member)
+ }
+ }
+
+ return toAdd, toRemove, toUpdate
+}
+
+func terraformHasMember(stateMembers []ProjectMemberItem, member client.ProjectMember) bool {
+ for _, m := range stateMembers {
+ if m.UserID.ValueString() == member.UserID || m.Email.ValueString() == member.Email || m.Username.ValueString() == member.Username {
+ return true
+ }
+ }
+ return false
+}
+
+func (r *projectMembersResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
+ var state ProjectMembersModel
+ diags := req.State.Get(ctx, &state)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ stateMembers, diags := state.members(ctx)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ members, err := r.client.ListProjectMembers(ctx, client.GetProjectMembersRequest{
+ TeamID: state.TeamID.ValueString(),
+ ProjectID: state.ProjectID.ValueString(),
+ })
+ if client.NotFound(err) {
+ resp.State.RemoveResource(ctx)
+ }
+ if err != nil {
+ resp.Diagnostics.AddError(
+ "Error reading Project Members",
+ fmt.Sprintf("Could not read Project Members, unexpected error: %s", err),
+ )
+ return
+ }
+
+ // Convert API response to model
+ var memberItems []attr.Value
+ for _, member := range members {
+ if terraformHasMember(stateMembers, member) {
+ memberItems = append(memberItems, types.ObjectValueMust(memberAttrType.AttrTypes, map[string]attr.Value{
+ "user_id": types.StringValue(member.UserID),
+ "email": types.StringValue(member.Email),
+ "username": types.StringValue(member.Username),
+ "role": types.StringValue(member.Role),
+ }))
+ }
+ }
+
+ state.Members = types.SetValueMust(memberAttrType, memberItems)
+ diags = resp.State.Set(ctx, &state)
+ resp.Diagnostics.Append(diags...)
+}
+
+func (r *projectMembersResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
+ var plan, state ProjectMembersModel
+ resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+ resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ planMembers, diags := plan.members(ctx)
+ if diags.HasError() {
+ resp.Diagnostics.Append(diags...)
+ return
+ }
+
+ // Get current members
+ currentMembers, err := r.client.ListProjectMembers(ctx, client.GetProjectMembersRequest{
+ ProjectID: plan.ProjectID.ValueString(),
+ TeamID: plan.TeamID.ValueString(),
+ })
+ if err != nil {
+ resp.Diagnostics.AddError(
+ "Error reading current Project Members",
+ fmt.Sprintf("Could not read current Project Members: %s", err),
+ )
+ return
+ }
+
+ // Create a map of current members for easy lookup
+ currentMemberMap := make(map[string]client.ProjectMember)
+ for _, member := range currentMembers {
+ currentMemberMap[member.UserID] = member
+ }
+
+ // Process planned members
+ plannedMembers, diags := plan.members(ctx)
+ if diags.HasError() {
+ resp.Diagnostics.Append(diags...)
+ return
+ }
+
+ stateMembers, diags := state.members(ctx)
+ if diags.HasError() {
+ resp.Diagnostics.Append(diags...)
+ return
+ }
+
+ toAdd, toRemove, toUpdate := diffMembers(stateMembers, plannedMembers)
+ tflog.Info(ctx, "update project members", map[string]interface{}{
+ "toAdd": toAdd,
+ "toRemove": toRemove,
+ "toUpdate": toUpdate,
+ })
+
+ // Remove members that are no longer in the plan
+ var remove []string
+ for _, r := range toRemove {
+ remove = append(remove, r.UserID.ValueString())
+ }
+
+ if len(remove) > 0 {
+ err = r.client.RemoveProjectMembers(ctx, client.RemoveProjectMembersRequest{
+ ProjectID: plan.ProjectID.ValueString(),
+ TeamID: plan.TeamID.ValueString(),
+ Members: remove,
+ })
+ if err != nil {
+ resp.Diagnostics.AddError(
+ "Error removing Project Members",
+ fmt.Sprintf("Could not remove Project Members: %s", err),
+ )
+ return
+ }
+ }
+
+ var add []client.ProjectMember
+ for _, a := range toAdd {
+ add = append(add, client.ProjectMember{
+ UserID: a.UserID.ValueString(),
+ Username: a.Username.ValueString(),
+ Email: a.Email.ValueString(),
+ Role: a.Role.ValueString(),
+ })
+ }
+ if len(add) > 0 {
+ err = r.client.AddProjectMembers(ctx, client.AddProjectMembersRequest{
+ ProjectID: plan.ProjectID.ValueString(),
+ TeamID: plan.TeamID.ValueString(),
+ Members: add,
+ })
+ if err != nil {
+ resp.Diagnostics.AddError(
+ "Error adding Project Members",
+ fmt.Sprintf("Could not add Project Members: %s", err),
+ )
+ return
+ }
+ }
+
+ var update []client.UpdateProjectMemberRequest
+ for _, u := range toUpdate {
+ update = append(update, client.UpdateProjectMemberRequest{
+ UserID: u.UserID.ValueString(),
+ Role: u.Role.ValueString(),
+ })
+ }
+ if len(update) > 0 {
+ err = r.client.UpdateProjectMembers(ctx, client.UpdateProjectMembersRequest{
+ ProjectID: plan.ProjectID.ValueString(),
+ TeamID: plan.TeamID.ValueString(),
+ Members: update,
+ })
+
+ if err != nil {
+ resp.Diagnostics.AddError(
+ "Error updating Project Members",
+ fmt.Sprintf("Could not update Project Members: %s", err),
+ )
+ return
+ }
+ }
+
+ members, err := r.client.ListProjectMembers(ctx, client.GetProjectMembersRequest{
+ TeamID: state.TeamID.ValueString(),
+ ProjectID: state.ProjectID.ValueString(),
+ })
+ tflog.Info(ctx, "read project members", map[string]interface{}{
+ "team_id": state.TeamID.ValueString(),
+ "project_id": state.ProjectID.ValueString(),
+ "members": members,
+ })
+ if err != nil {
+ resp.Diagnostics.AddError(
+ "Error reading Project Members",
+ fmt.Sprintf("Could not read Project Members, unexpected error: %s", err),
+ )
+ return
+ }
+
+ // Convert API response to model
+ var memberItems []attr.Value
+ for _, member := range members {
+ if terraformHasMember(planMembers, member) {
+ memberItems = append(memberItems, types.ObjectValueMust(memberAttrType.AttrTypes, map[string]attr.Value{
+ "user_id": types.StringValue(member.UserID),
+ "email": types.StringValue(member.Email),
+ "username": types.StringValue(member.Username),
+ "role": types.StringValue(member.Role),
+ }))
+ }
+ }
+
+ state.Members = types.SetValueMust(memberAttrType, memberItems)
+ diags = resp.State.Set(ctx, &state)
+ resp.Diagnostics.Append(diags...)
+}
+
+func (r *projectMembersResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
+ var state ProjectMembersModel
+ diags := req.State.Get(ctx, &state)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ members, diags := state.members(ctx)
+ if diags.HasError() {
+ resp.Diagnostics.Append(diags...)
+ return
+ }
+
+ var remove []string
+ for _, m := range members {
+ remove = append(remove, m.UserID.ValueString())
+ }
+
+ err := r.client.RemoveProjectMembers(ctx, client.RemoveProjectMembersRequest{
+ ProjectID: state.ProjectID.ValueString(),
+ TeamID: state.TeamID.ValueString(),
+ Members: remove,
+ })
+ if client.NotFound(err) {
+ resp.State.RemoveResource(ctx)
+ }
+ if err != nil {
+ resp.Diagnostics.AddError(
+ "Error removing Project Members",
+ fmt.Sprintf("Could not remove Project Members: %s", err),
+ )
+ return
+ }
+
+}
diff --git a/vercel/resource_project_members_test.go b/vercel/resource_project_members_test.go
new file mode 100644
index 00000000..d54d6b9e
--- /dev/null
+++ b/vercel/resource_project_members_test.go
@@ -0,0 +1,105 @@
+package vercel_test
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/hashicorp/terraform-plugin-testing/helper/acctest"
+ "github.com/hashicorp/terraform-plugin-testing/helper/resource"
+)
+
+func TestAcc_ProjectMembers(t *testing.T) {
+ projectSuffix := acctest.RandString(16)
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheck(t) },
+ ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
+ CheckDestroy: testAccProjectDestroy("vercel_project.test", testTeam()),
+ Steps: []resource.TestStep{
+ {
+ Config: testAccProjectMembersConfig(projectSuffix),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttrSet("vercel_project_members.test", "project_id"),
+ resource.TestCheckResourceAttr("vercel_project_members.test", "members.#", "1"),
+ ),
+ },
+ {
+ Config: testAccProjectMembersConfigUpdated(projectSuffix),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttrSet("vercel_project_members.test", "project_id"),
+ resource.TestCheckResourceAttr("vercel_project_members.test", "members.#", "2"),
+ ),
+ },
+ {
+ Config: testAccProjectMembersConfigUpdatedAgain(projectSuffix),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttrSet("vercel_project_members.test", "project_id"),
+ resource.TestCheckResourceAttr("vercel_project_members.test", "members.#", "1"),
+ ),
+ },
+ },
+ })
+}
+
+func testAccProjectMembersConfig(projectSuffix string) string {
+ return fmt.Sprintf(`
+resource "vercel_project" "test" {
+ name = "test-acc-project-members-%[1]s"
+ %[2]s
+}
+
+resource "vercel_project_members" "test" {
+ project_id = vercel_project.test.id
+ %[2]s
+
+ members = [{
+ email = "doug+test2@vercel.com"
+ role = "PROJECT_VIEWER"
+ }]
+}
+`, projectSuffix, teamIDConfig())
+}
+
+func testAccProjectMembersConfigUpdated(projectSuffix string) string {
+ return fmt.Sprintf(`
+resource "vercel_project" "test" {
+ name = "test-acc-project-members-%[1]s"
+ %[2]s
+}
+
+resource "vercel_project_members" "test" {
+ project_id = vercel_project.test.id
+ %[2]s
+
+ members = [{
+ email = "doug+test2@vercel.com"
+ role = "PROJECT_DEVELOPER"
+ },
+ {
+ email = "doug+test3@vercel.com"
+ role = "PROJECT_VIEWER"
+ }
+ ]
+}
+`, projectSuffix, teamIDConfig())
+}
+
+func testAccProjectMembersConfigUpdatedAgain(projectSuffix string) string {
+ return fmt.Sprintf(`
+resource "vercel_project" "test" {
+ name = "test-acc-project-members-%[1]s"
+ %[2]s
+}
+
+resource "vercel_project_members" "test" {
+ project_id = vercel_project.test.id
+ %[2]s
+
+ members = [
+ {
+ email = "doug+test3@vercel.com"
+ role = "PROJECT_VIEWER"
+ }
+ ]
+}
+`, projectSuffix, teamIDConfig())
+}