From a133383b437c5c38c3e4753ff64b075388bcfe87 Mon Sep 17 00:00:00 2001 From: Mateusz Filipowicz Date: Sat, 22 Mar 2025 17:31:30 +0100 Subject: [PATCH] feat: support customizing guest portal logo and background with `unifi_portal_file` and `unifi_setting_guest_access` resources (#74) * feat: support customizing guest portal logo and background with `unifi_portal_file` and `unifi_setting_guest_access` resources * ci: run acceptance tests on go.mod changes * f --- .gitattributes | 3 - .github/workflows/acctest.yml | 21 +- internal/provider/acctest/files/testfile.png | Bin 0 -> 9761 bytes internal/provider/acctest/files/testfile2.jpg | Bin 0 -> 13676 bytes .../acctest/resource_portal_file_test.go | 61 ++++++ .../resource_setting_guest_access_test.go | 33 +++- .../provider/portal/resource_portal_file.go | 179 ++++++++++++++++++ internal/provider/provider_v2.go | 3 +- .../settings/resource_setting_guest_access.go | 53 ++---- 9 files changed, 306 insertions(+), 47 deletions(-) create mode 100644 internal/provider/acctest/files/testfile.png create mode 100644 internal/provider/acctest/files/testfile2.jpg create mode 100644 internal/provider/acctest/resource_portal_file_test.go create mode 100644 internal/provider/portal/resource_portal_file.go diff --git a/.gitattributes b/.gitattributes index 5037dfa..645213a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,9 +1,6 @@ * text=auto - *.go diff=golang - *.sh eol=lf - *.jar binary *.wt binary *.bson binary diff --git a/.github/workflows/acctest.yml b/.github/workflows/acctest.yml index ec8dbc1..8f3cec3 100644 --- a/.github/workflows/acctest.yml +++ b/.github/workflows/acctest.yml @@ -1,14 +1,17 @@ name: Acceptance Tests on: pull_request: - paths: - - "internal/**" - - "scripts/**" - - "tools/**" - - "main.go" - - "docker-compose.yaml" - - ".github/workflows/acctest.yml" - - "Makefile" + branches: + - "*" +# paths: +# - "internal/**" +# - "scripts/**" +# - "tools/**" +# - "main.go" +# - "docker-compose.yaml" +# - ".github/workflows/acctest.yml" +# - "Makefile" +# - "go.mod" push: branches: - "main" @@ -24,6 +27,8 @@ on: - "Makefile" schedule: - cron: "0 13 * * *" + workflow_dispatch: + concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/internal/provider/acctest/files/testfile.png b/internal/provider/acctest/files/testfile.png new file mode 100644 index 0000000000000000000000000000000000000000..49cbdb58aa83e54a33d3ec0273a8587d7f91ba2e GIT binary patch literal 9761 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 literal 0 HcmV?d00001 diff --git a/internal/provider/acctest/files/testfile2.jpg b/internal/provider/acctest/files/testfile2.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d6badef328bf48f34571d3027c32c6fad39f1fd1 GIT binary patch literal 13676 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

6n7p}p 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 G|0V$3xePb} literal 0 HcmV?d00001 diff --git a/internal/provider/acctest/resource_portal_file_test.go b/internal/provider/acctest/resource_portal_file_test.go new file mode 100644 index 0000000..bbd99ed --- /dev/null +++ b/internal/provider/acctest/resource_portal_file_test.go @@ -0,0 +1,61 @@ +package acctest + +import ( + "context" + "fmt" + pt "github.com/filipowm/terraform-provider-unifi/internal/provider/testing" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "path/filepath" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +func TestAccPortalFile_basic(t *testing.T) { + + AcceptanceTest(t, AcceptanceTestCase{ + CheckDestroy: testAccCheckPortalFileDestroy, + Steps: []resource.TestStep{ + { + Config: testAccPortalFileConfig("files/testfile.png"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_portal_file.test", "site", "default"), + resource.TestCheckResourceAttrSet("unifi_portal_file.test", "filename"), + resource.TestCheckResourceAttrSet("unifi_portal_file.test", "content_type"), + resource.TestCheckResourceAttrSet("unifi_portal_file.test", "file_size"), + resource.TestCheckResourceAttrSet("unifi_portal_file.test", "md5"), + resource.TestCheckResourceAttrSet("unifi_portal_file.test", "url"), + ), + ConfigPlanChecks: pt.CheckResourceActions("unifi_portal_file.test", plancheck.ResourceActionCreate), + }, + { + Config: testAccPortalFileConfig("files/testfile2.jpg"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_portal_file.test", "site", "default"), + resource.TestCheckResourceAttrSet("unifi_portal_file.test", "filename"), + resource.TestCheckResourceAttrSet("unifi_portal_file.test", "content_type"), + resource.TestCheckResourceAttrSet("unifi_portal_file.test", "file_size"), + resource.TestCheckResourceAttrSet("unifi_portal_file.test", "md5"), + resource.TestCheckResourceAttrSet("unifi_portal_file.test", "url"), + ), + ConfigPlanChecks: pt.CheckResourceActions("unifi_portal_file.test", plancheck.ResourceActionReplace), + }, + }, + }) +} + +func testAccCheckPortalFileDestroy(s *terraform.State) error { + return pt.CheckDestroy("unifi_portal_file", func(ctx context.Context, site, id string) error { + _, err := testClient.GetPortalFile(ctx, site, id) + return err + })(s) +} + +func testAccPortalFileConfig(filePath string) string { + return fmt.Sprintf(` +resource "unifi_portal_file" "test" { + file_path = %q +} +`, filepath.ToSlash(filePath)) +} diff --git a/internal/provider/acctest/resource_setting_guest_access_test.go b/internal/provider/acctest/resource_setting_guest_access_test.go index bd2d830..f760a86 100644 --- a/internal/provider/acctest/resource_setting_guest_access_test.go +++ b/internal/provider/acctest/resource_setting_guest_access_test.go @@ -702,6 +702,15 @@ func TestAccSettingGuestAccess_portalCustomizationPostVersion74(t *testing.T) { resource.TestCheckResourceAttr("unifi_setting_guest_access.test", "portal_customization.logo_size", "150"), ), }, + { + Config: testAccSettingGuestAccessConfig_portalCustomizationImagesPost74(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_guest_access.test", "portal_customization.customized", "true"), + resource.TestCheckResourceAttr("unifi_setting_guest_access.test", "portal_customization.bg_type", "image"), + resource.TestCheckResourceAttrSet("unifi_setting_guest_access.test", "portal_customization.bg_image_file_id"), + resource.TestCheckResourceAttrSet("unifi_setting_guest_access.test", "portal_customization.logo_file_id"), + ), + }, }, }) } @@ -758,7 +767,7 @@ func TestAccSettingGuestAccess_portalCustomization(t *testing.T) { Config: testAccSettingGuestAccessConfig_basic(), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("unifi_setting_guest_access.test", "portal_customization.customized", "false"), - resource.TestCheckResourceAttr("unifi_setting_guest_access.test", "portal_customization.%", "27"), + resource.TestCheckResourceAttr("unifi_setting_guest_access.test", "portal_customization.%", "29"), ), }, }, @@ -1098,6 +1107,28 @@ resource "unifi_setting_guest_access" "test" { ` } +func testAccSettingGuestAccessConfig_portalCustomizationImagesPost74() string { + return ` +resource "unifi_portal_file" "logo" { + file_path = "files/testfile.png" +} + +resource "unifi_portal_file" "background" { + file_path = "files/testfile2.jpg" +} + +resource "unifi_setting_guest_access" "test" { + auth = "none" + portal_customization = { + customized = true + bg_type = "image" + bg_image_file_id = unifi_portal_file.background.id + logo_file_id = unifi_portal_file.logo.id + } +} +` +} + func testAccSettingGuestAccessConfig_portalCustomizationGallery() string { return ` resource "unifi_setting_guest_access" "test" { diff --git a/internal/provider/portal/resource_portal_file.go b/internal/provider/portal/resource_portal_file.go new file mode 100644 index 0000000..ce6103a --- /dev/null +++ b/internal/provider/portal/resource_portal_file.go @@ -0,0 +1,179 @@ +package portal + +import ( + "context" + "fmt" + "github.com/filipowm/go-unifi/unifi" + "github.com/filipowm/terraform-provider-unifi/internal/provider/base" + ut "github.com/filipowm/terraform-provider-unifi/internal/provider/types" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "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" + "os" +) + +var ( + _ resource.Resource = &portalFileResource{} + _ resource.ResourceWithConfigure = &portalFileResource{} + _ resource.ResourceWithImportState = &portalFileResource{} + _ base.Resource = &portalFileResource{} +) + +type portalFileResource struct { + *base.GenericResource[*portalFileModel] +} + +type portalFileModel struct { + base.Model + Filename types.String `tfsdk:"filename"` + FilePath types.String `tfsdk:"file_path"` + ContentType types.String `tfsdk:"content_type"` + FileSize types.Int64 `tfsdk:"file_size"` + MD5 types.String `tfsdk:"md5"` + URL types.String `tfsdk:"url"` + LastModified types.Int64 `tfsdk:"last_modified"` +} + +func (m *portalFileModel) Merge(_ context.Context, data interface{}) diag.Diagnostics { + var diags diag.Diagnostics + portalFile, ok := data.(*unifi.PortalFile) + if !ok { + diags.AddError("Invalid data type", fmt.Sprintf("Expected *unifi.PortalFile, got: %T", data)) + return diags + } + + m.ID = types.StringValue(portalFile.ID) + m.Filename = types.StringValue(portalFile.Filename) + m.ContentType = types.StringValue(portalFile.ContentType) + m.FileSize = types.Int64Value(int64(portalFile.FileSize)) + m.MD5 = types.StringValue(portalFile.MD5) + m.URL = types.StringValue(portalFile.URL) + m.LastModified = types.Int64Value(int64(portalFile.LastModified)) + + return diags +} + +func (m *portalFileModel) AsUnifiModel(_ context.Context) (interface{}, diag.Diagnostics) { + // Not used for upload - we don't convert the model to a UniFi model + // The file path is used directly for upload + return nil, diag.Diagnostics{} +} + +func NewPortalFileResource() resource.Resource { + return &portalFileResource{ + GenericResource: base.NewGenericResource( + "unifi_portal_file", + func() *portalFileModel { return &portalFileModel{} }, + base.ResourceFunctions{ + Read: func(ctx context.Context, client *base.Client, site, id string) (interface{}, error) { + return client.GetPortalFile(ctx, site, id) + }, + Create: nil, // Custom implementation in CreateWithContext + Update: nil, // Portal files cannot be updated, only replaced + Delete: func(ctx context.Context, client *base.Client, site, id string) error { + return client.DeletePortalFile(ctx, site, id) + }, + }, + ), + } +} + +func (r *portalFileResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "The `unifi_portal_file` resource manages files uploaded to the UniFi guest portal. " + + "This resource allows you to upload images that can be used in customizing " + + "the UniFi guest portal interface.\n\n" + + "**Note:** This resource uploads files to the UniFi controller. The file must exist on the local filesystem " + + "where Terraform is executed.", + + Attributes: map[string]schema.Attribute{ + "id": ut.ID(), + "site": ut.SiteAttribute(), + "file_path": schema.StringAttribute{ + MarkdownDescription: "Path to the file on the local filesystem to upload to the UniFi controller. " + + "The file must exist and be readable.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "filename": schema.StringAttribute{ + MarkdownDescription: "Name of the file as stored in the UniFi controller.", + Computed: true, + }, + "content_type": schema.StringAttribute{ + MarkdownDescription: "MIME type of the file.", + Computed: true, + }, + "file_size": schema.Int64Attribute{ + MarkdownDescription: "Size of the file in bytes.", + Computed: true, + }, + "md5": schema.StringAttribute{ + MarkdownDescription: "MD5 hash of the file content.", + Computed: true, + }, + "url": schema.StringAttribute{ + MarkdownDescription: "URL where the file can be accessed on the UniFi controller.", + Computed: true, + }, + "last_modified": schema.Int64Attribute{ + MarkdownDescription: "Timestamp when the file was last modified.", + Computed: true, + }, + }, + } +} + +func (r *portalFileResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data portalFileModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Get file path + filePath := data.FilePath.ValueString() + if filePath == "" { + resp.Diagnostics.AddError("File path is required", "A valid file path must be provided") + return + } + + // Check if file exists + _, err := os.Stat(filePath) + if err != nil { + resp.Diagnostics.AddError("Invalid file path", fmt.Sprintf("Error accessing file: %s", err)) + return + } + site := r.GetClient().ResolveSite(&data) + + portalFile, err := r.GetClient().UploadPortalFile(ctx, site, filePath) + if err != nil { + resp.Diagnostics.AddError("Error uploading file", fmt.Sprintf("Could not upload file: %s", err)) + return + } + + // Map response back to model + resp.Diagnostics.Append(data.Merge(ctx, portalFile)...) + if resp.Diagnostics.HasError() { + return + } + data.Site = types.StringValue(site) + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *portalFileResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resp.Diagnostics.AddError("Import is not supported", "The `unifi_portal_file` resource does not support import") +} diff --git a/internal/provider/provider_v2.go b/internal/provider/provider_v2.go index 2a0c94d..8754576 100644 --- a/internal/provider/provider_v2.go +++ b/internal/provider/provider_v2.go @@ -5,6 +5,7 @@ import ( "github.com/filipowm/terraform-provider-unifi/internal/provider/base" "github.com/filipowm/terraform-provider-unifi/internal/provider/dns" "github.com/filipowm/terraform-provider-unifi/internal/provider/firewall" + "github.com/filipowm/terraform-provider-unifi/internal/provider/portal" "github.com/filipowm/terraform-provider-unifi/internal/provider/settings" "github.com/filipowm/terraform-provider-unifi/internal/provider/utils" "github.com/filipowm/terraform-provider-unifi/internal/provider/validators" @@ -177,7 +178,7 @@ func (p *unifiProvider) Resources(_ context.Context) []func() resource.Resource dns.NewDnsRecordResource, firewall.NewFirewallZoneResource, firewall.NewFirewallZonePolicyResource, - //portal.NewPortalFileResource, + portal.NewPortalFileResource, settings.NewAutoSpeedtestResource, settings.NewCountryResource, settings.NewDpiResource, diff --git a/internal/provider/settings/resource_setting_guest_access.go b/internal/provider/settings/resource_setting_guest_access.go index 3a50375..40fa878 100644 --- a/internal/provider/settings/resource_setting_guest_access.go +++ b/internal/provider/settings/resource_setting_guest_access.go @@ -98,6 +98,7 @@ type portalCustomizationModel struct { Customized types.Bool `tfsdk:"customized"` AuthenticationText types.String `tfsdk:"authentication_text"` BgColor types.String `tfsdk:"bg_color"` + BgImageFileId types.String `tfsdk:"bg_image_file_id"` BgImageTile types.Bool `tfsdk:"bg_image_tile"` BgType types.String `tfsdk:"bg_type"` BoxColor types.String `tfsdk:"box_color"` @@ -110,6 +111,7 @@ type portalCustomizationModel struct { ButtonTextColor types.String `tfsdk:"button_text_color"` Languages types.List `tfsdk:"languages"` LinkColor types.String `tfsdk:"link_color"` + LogoFileId types.String `tfsdk:"logo_file_id"` LogoPosition types.String `tfsdk:"logo_position"` LogoSize types.Int32 `tfsdk:"logo_size"` SuccessText types.String `tfsdk:"success_text"` @@ -129,6 +131,7 @@ func (m *portalCustomizationModel) AttributeTypes() map[string]attr.Type { "customized": types.BoolType, "authentication_text": types.StringType, "bg_color": types.StringType, + "bg_image_file_id": types.StringType, "bg_image_tile": types.BoolType, "bg_type": types.StringType, "box_color": types.StringType, @@ -143,6 +146,7 @@ func (m *portalCustomizationModel) AttributeTypes() map[string]attr.Type { ElemType: types.StringType, }, "link_color": types.StringType, + "logo_file_id": types.StringType, "logo_position": types.StringType, "logo_size": types.Int32Type, "success_text": types.StringType, @@ -478,6 +482,7 @@ func (d *guestAccessModel) AsUnifiModel(ctx context.Context) (interface{}, diag. model.PortalCustomized = portalCustomization.Customized.ValueBool() model.PortalCustomizedAuthenticationText = portalCustomization.AuthenticationText.ValueString() model.PortalCustomizedBgColor = portalCustomization.BgColor.ValueString() + model.PortalCustomizedBgImageFilename = portalCustomization.BgImageFileId.ValueString() model.PortalCustomizedBgImageTile = portalCustomization.BgImageTile.ValueBool() model.PortalCustomizedBgType = portalCustomization.BgType.ValueString() model.PortalCustomizedBoxColor = portalCustomization.BoxColor.ValueString() @@ -490,6 +495,7 @@ func (d *guestAccessModel) AsUnifiModel(ctx context.Context) (interface{}, diag. model.PortalCustomizedButtonTextColor = portalCustomization.ButtonTextColor.ValueString() model.PortalCustomizedLanguages = languages model.PortalCustomizedLinkColor = portalCustomization.LinkColor.ValueString() + model.PortalCustomizedLogoFilename = portalCustomization.LogoFileId.ValueString() model.PortalCustomizedLogoPosition = portalCustomization.LogoPosition.ValueString() model.PortalCustomizedLogoSize = int(portalCustomization.LogoSize.ValueInt32()) model.PortalCustomizedSuccessText = portalCustomization.SuccessText.ValueString() @@ -816,6 +822,7 @@ func (d *guestAccessModel) Merge(ctx context.Context, unifiModel interface{}) di Customized: types.BoolValue(model.PortalCustomized), AuthenticationText: types.StringValue(model.PortalCustomizedAuthenticationText), BgColor: types.StringValue(model.PortalCustomizedBgColor), + BgImageFileId: types.StringValue(model.PortalCustomizedBgImageFilename), BgImageTile: types.BoolValue(model.PortalCustomizedBgImageTile), BgType: types.StringValue(model.PortalCustomizedBgType), BoxColor: types.StringValue(model.PortalCustomizedBoxColor), @@ -828,6 +835,7 @@ func (d *guestAccessModel) Merge(ctx context.Context, unifiModel interface{}) di ButtonTextColor: types.StringValue(model.PortalCustomizedButtonTextColor), Languages: languages, LinkColor: types.StringValue(model.PortalCustomizedLinkColor), + LogoFileId: types.StringValue(model.PortalCustomizedLogoFilename), LogoPosition: types.StringValue(model.PortalCustomizedLogoPosition), LogoSize: types.Int32Value(int32(model.PortalCustomizedLogoSize)), SuccessText: types.StringValue(model.PortalCustomizedSuccessText), @@ -880,14 +888,6 @@ func (g *guestAccessResource) ModifyPlan(_ context.Context, req resource.ModifyP resp.Diagnostics.Append(g.RequireMinVersionForPath("7.4", path.Root("portal_customization").AtName("logo_position"), req.Config)...) } -func requiredTogetherIfTrue(condition string, attrs ...string) validators.RequiredTogetherIfValidator { - var expressions []path.Expression - for _, attr := range attrs { - expressions = append(expressions, path.MatchRoot(attr)) - } - return validators.RequiredTogetherIf(path.MatchRoot(condition), types.BoolValue(true), expressions...) -} - func requiredTogetherIfStringVal(condition, value string, attrs ...string) validators.RequiredTogetherIfValidator { var expressions []path.Expression for _, attr := range attrs { @@ -904,17 +904,6 @@ func (g *guestAccessResource) ConfigValidators(_ context.Context) []resource.Con return []resource.ConfigValidator{ // Auth validators requiredTogetherIfStringVal("auth", "custom", "custom_ip"), - //requiredTogetherIfStringVal("auth", "facebook_wifi", "facebook_wifi.gateway_id", "facebook_wifi.gateway_name", "facebook_wifi.gateway_secret"), - - // Facebook validators - - // Google validators - requiredTogetherIfTrue("google.enabled", "google.client_id", "google.client_secret"), - requiredStringValueIfTrue("google.enabled", "auth", "hotspot"), - - // Password validators - requiredTogetherIfTrue("password_enabled", "password"), - requiredStringValueIfTrue("password_enabled", "auth", "hotspot"), // Payment validators requiredTogetherIfStringVal("payment_gateway", "authorize", "authorize"), @@ -925,21 +914,7 @@ func (g *guestAccessResource) ConfigValidators(_ context.Context) []resource.Con requiredTogetherIfStringVal("payment_gateway", "stripe", "stripe"), // Portal validators - //requiredTogetherIfStringVal("portal_customized_bg_type", "color", "portal_customized_bg_color"), - //requiredTogetherIfStringVal("portal_customized_bg_type", "gallery", "portal_customized_unsplash_author_name", "portal_customized_unsplash_author_username"), - //requiredTogetherIfStringVal("portal_customized_bg_type", "image", "portal_customized_bg_image_filename"), - //requiredTogetherIfTrue("portal_customized_bg_image_enabled", "portal_customized_bg_image_filename"), - //requiredTogetherIfTrue("portal_customized_logo_enabled", "portal_customized_logo_filename"), - //requiredTogetherIfTrue("portal_customized_tos_enabled", "portal_customized_tos"), - //requiredTogetherIfTrue("portal_customized_welcome_text_enabled", "portal_customized_welcome_text"), - //requiredTogetherIfTrue("portal_use_hostname", "portal_hostname"), - - // RADIUS validators - //requiredTogetherIfTrue("radius_disconnect_enabled", "radius_disconnect_port"), - //requiredTogetherIfTrue("radius_enabled", "radius_auth_type", "radius_profile_id"), - - // Restricted DNS validators - //requiredTogetherIfTrue("restricted_dns_enabled", "restricted_dns_servers"), + requiredTogetherIfStringVal("portal_customization.bg_type", "image", "portal_customization.bg_image_file_id"), // Voucher validators requiredStringValueIfTrue("voucher_enabled", "auth", "hotspot"), @@ -1251,6 +1226,11 @@ func (g *guestAccessResource) Schema(_ context.Context, _ resource.SchemaRequest validators.HexColor, }, }, + "bg_image_file_id": schema.StringAttribute{ + MarkdownDescription: "ID of the background image portal file. File must exist in controller, use `unifi_portal_file` to manage it.", + Optional: true, + Computed: true, + }, "bg_image_tile": schema.BoolAttribute{ MarkdownDescription: "Tile the background image.", Optional: true, @@ -1342,6 +1322,11 @@ func (g *guestAccessResource) Schema(_ context.Context, _ resource.SchemaRequest validators.HexColor, }, }, + "logo_file_id": schema.StringAttribute{ + MarkdownDescription: "ID of the logo image portal file. File must exist in controller, use `unifi_portal_file` to manage it.", + Optional: true, + Computed: true, + }, "logo_position": schema.StringAttribute{ MarkdownDescription: "Position of the logo in the portal. Valid values are: left, center, right.", Optional: true,