diff --git a/apps/console/src/pages/iam/memberships/_components/MembershipCard.tsx b/apps/console/src/pages/iam/memberships/_components/MembershipCard.tsx index 7c0bfff13..5617e3f2c 100644 --- a/apps/console/src/pages/iam/memberships/_components/MembershipCard.tsx +++ b/apps/console/src/pages/iam/memberships/_components/MembershipCard.tsx @@ -46,7 +46,9 @@ const organizationFragment = graphql` fragment MembershipCard_organizationFragment on Organization { id name - logoUrl + logo { + downloadUrl + } } `; @@ -101,7 +103,7 @@ export function MembershipCard(props: MembershipCardProps) {
diff --git a/apps/console/src/pages/iam/organizations/_components/MembershipsDropdownMenuItem.tsx b/apps/console/src/pages/iam/organizations/_components/MembershipsDropdownMenuItem.tsx index 949de345f..ba9aa8360 100644 --- a/apps/console/src/pages/iam/organizations/_components/MembershipsDropdownMenuItem.tsx +++ b/apps/console/src/pages/iam/organizations/_components/MembershipsDropdownMenuItem.tsx @@ -41,7 +41,9 @@ const organizationFragment = graphql` fragment MembershipsDropdownMenuItem_organizationFragment on Organization { id name - logoUrl + logo { + downloadUrl + } } `; @@ -63,7 +65,7 @@ export function MembershipsDropdownMenuItem(props: { return ( - + {organization.name} {isAssuming && ( diff --git a/apps/console/src/pages/iam/organizations/settings/_components/OrganizationForm.tsx b/apps/console/src/pages/iam/organizations/settings/_components/OrganizationForm.tsx index aef8a8e78..3c27228d2 100644 --- a/apps/console/src/pages/iam/organizations/settings/_components/OrganizationForm.tsx +++ b/apps/console/src/pages/iam/organizations/settings/_components/OrganizationForm.tsx @@ -41,8 +41,12 @@ const fragment = graphql` fragment OrganizationFormFragment on Organization { id name @required(action: THROW) - logoUrl - horizontalLogoUrl + logo { + downloadUrl + } + horizontalLogo { + downloadUrl + } description websiteUrl email @@ -57,8 +61,12 @@ const updateOrganizationMutation = graphql` organization { id name - logoUrl - horizontalLogoUrl + logo { + downloadUrl + } + horizontalLogo { + downloadUrl + } description websiteUrl email @@ -75,7 +83,9 @@ const deleteHorizontalLogoMutation = graphql` deleteOrganizationHorizontalLogo(input: $input) { organization { id - horizontalLogoUrl + horizontalLogo { + downloadUrl + } } } } @@ -225,8 +235,8 @@ export function OrganizationForm(props: {
@@ -253,12 +263,12 @@ export function OrganizationForm(props: { )}

- {(horizontalLogoPreview || organization.horizontalLogoUrl) && ( + {(horizontalLogoPreview || organization.horizontalLogo?.downloadUrl) && (
{__("Horizontal {isUpdatingOrganization ? __("Uploading...") - : horizontalLogoPreview || organization.horizontalLogoUrl + : horizontalLogoPreview || organization.horizontalLogo?.downloadUrl ? __("Change horizontal logo") : __("Upload horizontal logo")} )} - {canUpdate && organization.horizontalLogoUrl && ( + {canUpdate && organization.horizontalLogo?.downloadUrl && ( . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package console_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/e2e/internal/testutil" +) + +func TestConnectOrganization_LogoUpload(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + organizationID := owner.GetOrganizationID().String() + + const uploadMutation = ` + mutation UpdateOrganization($input: UpdateOrganizationInput!) { + updateOrganization(input: $input) { + organization { + id + logo { + id + fileName + downloadUrl + } + } + } + } + ` + + pngContent := []byte{ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, + 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, + 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, + 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, + 0x42, 0x60, 0x82, + } + + var uploadResult struct { + UpdateOrganization struct { + Organization struct { + ID string `json:"id"` + Logo *struct { + ID string `json:"id"` + FileName string `json:"fileName"` + DownloadURL string `json:"downloadUrl"` + } `json:"logo"` + } `json:"organization"` + } `json:"updateOrganization"` + } + + err := owner.ExecuteConnectWithFile(uploadMutation, map[string]any{ + "input": map[string]any{ + "organizationId": organizationID, + "logoFile": nil, + }, + }, "input.logoFile", testutil.UploadFile{ + Filename: "org-logo.png", + ContentType: "image/png", + Content: pngContent, + }, &uploadResult) + require.NoError(t, err) + + assert.Equal(t, organizationID, uploadResult.UpdateOrganization.Organization.ID) + require.NotNil(t, uploadResult.UpdateOrganization.Organization.Logo) + assert.Equal(t, "org-logo.png", uploadResult.UpdateOrganization.Organization.Logo.FileName) + assert.NotEmpty(t, uploadResult.UpdateOrganization.Organization.Logo.DownloadURL) + assert.True( + t, + strings.Contains(uploadResult.UpdateOrganization.Organization.Logo.DownloadURL, "/api/files/v1/public/"), + "downloadUrl must route through the public files API, got %q", + uploadResult.UpdateOrganization.Organization.Logo.DownloadURL, + ) + + const queryOrganization = ` + query GetOrganization($id: ID!) { + node(id: $id) { + ... on Organization { + logo { + downloadUrl + fileName + mimeType + size + } + } + } + } + ` + + var queryResult struct { + Node struct { + Logo *struct { + DownloadURL string `json:"downloadUrl"` + FileName string `json:"fileName"` + MimeType string `json:"mimeType"` + Size int64 `json:"size"` + } `json:"logo"` + } `json:"node"` + } + + err = owner.ExecuteConnect(queryOrganization, map[string]any{ + "id": organizationID, + }, &queryResult) + require.NoError(t, err) + + require.NotNil(t, queryResult.Node.Logo) + assert.Equal(t, "org-logo.png", queryResult.Node.Logo.FileName) + assert.Equal(t, "image/png", queryResult.Node.Logo.MimeType) + assert.Equal(t, int64(len(pngContent)), queryResult.Node.Logo.Size) + assert.True( + t, + strings.Contains(queryResult.Node.Logo.DownloadURL, "/api/files/v1/public/"), + "downloadUrl must route through the public files API, got %q", + queryResult.Node.Logo.DownloadURL, + ) +} diff --git a/e2e/internal/testutil/graphql.go b/e2e/internal/testutil/graphql.go index 2360d7aa4..c15efaaa2 100644 --- a/e2e/internal/testutil/graphql.go +++ b/e2e/internal/testutil/graphql.go @@ -185,14 +185,18 @@ type UploadFile struct { } func (c *Client) ExecuteWithFile(query string, variables map[string]any, variablePath string, file UploadFile, result any) error { - return c.executeMultipart(query, variables, map[string]UploadFile{variablePath: file}, result) + return c.executeMultipart("/api/console/v1/graphql", query, variables, map[string]UploadFile{variablePath: file}, result) +} + +func (c *Client) ExecuteConnectWithFile(query string, variables map[string]any, variablePath string, file UploadFile, result any) error { + return c.executeMultipart("/api/connect/v1/graphql", query, variables, map[string]UploadFile{variablePath: file}, result) } func (c *Client) ExecuteWithFiles(query string, variables map[string]any, files map[string]UploadFile, result any) error { - return c.executeMultipart(query, variables, files, result) + return c.executeMultipart("/api/console/v1/graphql", query, variables, files, result) } -func (c *Client) executeMultipart(query string, variables map[string]any, files map[string]UploadFile, result any) error { +func (c *Client) executeMultipart(endpoint string, query string, variables map[string]any, files map[string]UploadFile, result any) error { // Create multipart writer using standard library var buf bytes.Buffer @@ -262,7 +266,7 @@ func (c *Client) executeMultipart(query string, variables map[string]any, files } // Create request - req, err := http.NewRequest("POST", c.baseURL+"/api/console/v1/graphql", &buf) + req, err := http.NewRequest("POST", c.baseURL+endpoint, &buf) if err != nil { return fmt.Errorf("cannot create request: %w", err) } diff --git a/packages/n8n-node/nodes/Probo/actions/organization/create.operation.ts b/packages/n8n-node/nodes/Probo/actions/organization/create.operation.ts index cd267fa5a..b40cc37e4 100644 --- a/packages/n8n-node/nodes/Probo/actions/organization/create.operation.ts +++ b/packages/n8n-node/nodes/Probo/actions/organization/create.operation.ts @@ -48,8 +48,16 @@ export async function execute( websiteUrl email headquarterAddress - logoUrl - horizontalLogoUrl + logo { + id + fileName + downloadUrl + } + horizontalLogo { + id + fileName + downloadUrl + } createdAt updatedAt } diff --git a/packages/n8n-node/nodes/Probo/actions/organization/get.operation.ts b/packages/n8n-node/nodes/Probo/actions/organization/get.operation.ts index 5a55439b8..124d98459 100644 --- a/packages/n8n-node/nodes/Probo/actions/organization/get.operation.ts +++ b/packages/n8n-node/nodes/Probo/actions/organization/get.operation.ts @@ -48,8 +48,16 @@ export async function execute( websiteUrl email headquarterAddress - logoUrl - horizontalLogoUrl + logo { + id + fileName + downloadUrl + } + horizontalLogo { + id + fileName + downloadUrl + } createdAt updatedAt } diff --git a/packages/n8n-node/nodes/Probo/actions/organization/getAll.operation.ts b/packages/n8n-node/nodes/Probo/actions/organization/getAll.operation.ts index 23f3f415f..be3b81a69 100644 --- a/packages/n8n-node/nodes/Probo/actions/organization/getAll.operation.ts +++ b/packages/n8n-node/nodes/Probo/actions/organization/getAll.operation.ts @@ -68,8 +68,16 @@ export async function execute( websiteUrl email headquarterAddress - logoUrl - horizontalLogoUrl + logo { + id + fileName + downloadUrl + } + horizontalLogo { + id + fileName + downloadUrl + } createdAt updatedAt } diff --git a/packages/n8n-node/nodes/Probo/actions/organization/update.operation.ts b/packages/n8n-node/nodes/Probo/actions/organization/update.operation.ts index 25c0b9330..736a4dd15 100644 --- a/packages/n8n-node/nodes/Probo/actions/organization/update.operation.ts +++ b/packages/n8n-node/nodes/Probo/actions/organization/update.operation.ts @@ -119,8 +119,16 @@ export async function execute( websiteUrl email headquarterAddress - logoUrl - horizontalLogoUrl + logo { + id + fileName + downloadUrl + } + horizontalLogo { + id + fileName + downloadUrl + } createdAt updatedAt } diff --git a/pkg/iam/organization_service.go b/pkg/iam/organization_service.go index 146c4832a..5e53effee 100644 --- a/pkg/iam/organization_service.go +++ b/pkg/iam/organization_service.go @@ -1372,18 +1372,17 @@ func (s *OrganizationService) GetOrganizationForMembership(ctx context.Context, return organization, nil } -func (s OrganizationService) GenerateLogoURL( +func (s OrganizationService) LogoFile( ctx context.Context, organizationID gid.GID, - expiresIn time.Duration, -) (*string, error) { +) (*coredata.File, error) { var ( errNoLogoFile = errors.New("no logo file found") scope = coredata.NewScopeFromObjectID(organizationID) file = &coredata.File{} ) - err := s.pg.WithConn( + if err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { organization := &coredata.Organization{} @@ -1401,35 +1400,28 @@ func (s OrganizationService) GenerateLogoURL( return nil }, - ) - if err == errNoLogoFile { - return nil, nil + ); err != nil { + if errors.Is(err, errNoLogoFile) { + return nil, nil + } + + return nil, fmt.Errorf("cannot load logo file: %w", err) } - if err != nil { - return nil, fmt.Errorf("cannot generate logo URL: %w", err) - } - - downloadURL, err := s.fm.BuildDownloadURL(file) - if err != nil { - return nil, fmt.Errorf("cannot generate file URL: %w", err) - } - - return &downloadURL, nil + return file, nil } -func (s OrganizationService) GenerateHorizontalLogoURL( +func (s OrganizationService) HorizontalLogoFile( ctx context.Context, organizationID gid.GID, - expiresIn time.Duration, -) (*string, error) { +) (*coredata.File, error) { var ( errNoLogoFile = errors.New("no logo file found") scope = coredata.NewScopeFromObjectID(organizationID) file = &coredata.File{} ) - err := s.pg.WithConn( + if err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { organization := &coredata.Organization{} @@ -1447,21 +1439,15 @@ func (s OrganizationService) GenerateHorizontalLogoURL( return nil }, - ) - if err == errNoLogoFile { - return nil, nil + ); err != nil { + if errors.Is(err, errNoLogoFile) { + return nil, nil + } + + return nil, fmt.Errorf("cannot load horizontal logo file: %w", err) } - if err != nil { - return nil, err - } - - downloadURL, err := s.fm.BuildDownloadURL(file) - if err != nil { - return nil, fmt.Errorf("cannot generate file URL: %w", err) - } - - return &downloadURL, nil + return file, nil } func (s OrganizationService) DeleteSAMLConfiguration( diff --git a/pkg/server/api/connect/v1/gqlgen.yaml b/pkg/server/api/connect/v1/gqlgen.yaml index 09a93ed38..a42f27ca2 100644 --- a/pkg/server/api/connect/v1/gqlgen.yaml +++ b/pkg/server/api/connect/v1/gqlgen.yaml @@ -38,6 +38,9 @@ models: CursorKey: model: - "go.probo.inc/probo/pkg/server/gqlutils/types/cursor.CursorKeyScalar" + BigInt: + model: + - "go.probo.inc/probo/pkg/server/gqlutils/types/bigint.BigIntScalar" EmailAddr: model: - "go.probo.inc/probo/pkg/server/gqlutils/types/mail.AddrScalar" \ No newline at end of file diff --git a/pkg/server/api/connect/v1/graphql/base.graphql b/pkg/server/api/connect/v1/graphql/base.graphql index 896e0452c..2e7ee8b4c 100644 --- a/pkg/server/api/connect/v1/graphql/base.graphql +++ b/pkg/server/api/connect/v1/graphql/base.graphql @@ -11,6 +11,7 @@ directive @goModel( directive @goEnum(value: String) on ENUM_VALUE +scalar BigInt scalar CursorKey scalar Datetime scalar Upload diff --git a/pkg/server/api/connect/v1/graphql/file.graphql b/pkg/server/api/connect/v1/graphql/file.graphql new file mode 100644 index 000000000..612ddf172 --- /dev/null +++ b/pkg/server/api/connect/v1/graphql/file.graphql @@ -0,0 +1,12 @@ +# Connect File is PUBLIC-ONLY: downloadUrl points at /api/files/v1/public/{id} +# and carries no authorization. Do not expose a private file through this type +# without adding a forceResolver + auth on downloadUrl. +type File { + id: ID! + mimeType: String! + fileName: String! + size: BigInt! + downloadUrl: String! + createdAt: Datetime! + updatedAt: Datetime! +} diff --git a/pkg/server/api/connect/v1/graphql/organization.graphql b/pkg/server/api/connect/v1/graphql/organization.graphql index 329641d30..9a8b81829 100644 --- a/pkg/server/api/connect/v1/graphql/organization.graphql +++ b/pkg/server/api/connect/v1/graphql/organization.graphql @@ -1,8 +1,8 @@ type Organization implements Node { id: ID! name: String! - logoUrl: String @goField(forceResolver: true) - horizontalLogoUrl: String @goField(forceResolver: true) + logo: File @goField(forceResolver: true) + horizontalLogo: File @goField(forceResolver: true) email: String description: String websiteUrl: String diff --git a/pkg/server/api/connect/v1/organization_resolvers.go b/pkg/server/api/connect/v1/organization_resolvers.go index 7fe6cb632..d5a552a1f 100644 --- a/pkg/server/api/connect/v1/organization_resolvers.go +++ b/pkg/server/api/connect/v1/organization_resolvers.go @@ -9,7 +9,6 @@ import ( "context" "errors" "fmt" - "time" "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/coredata" @@ -18,7 +17,6 @@ import ( "go.probo.inc/probo/pkg/iam/scim/bridge/provider/microsoft365" "go.probo.inc/probo/pkg/page" "go.probo.inc/probo/pkg/server/api/authn" - "go.probo.inc/probo/pkg/server/api/authz" "go.probo.inc/probo/pkg/server/api/connect/v1/schema" "go.probo.inc/probo/pkg/server/api/connect/v1/types" "go.probo.inc/probo/pkg/server/gqlutils" @@ -158,34 +156,34 @@ func (r *mutationResolver) DeleteOrganizationHorizontalLogo(ctx context.Context, panic(fmt.Errorf("not implemented: DeleteOrganizationHorizontalLogo - deleteOrganizationHorizontalLogo")) } -// LogoURL is the resolver for the logoUrl field. -func (r *organizationResolver) LogoURL(ctx context.Context, obj *types.Organization) (*string, error) { - if _, err := r.authorize(ctx, obj.ID, iam.ActionOrganizationGet, authz.WithSkipAssumptionCheck()); err != nil { - return nil, err - } - - presignedURL, err := r.iam.OrganizationService.GenerateLogoURL(ctx, obj.ID, 1*time.Hour) +// Logo is the resolver for the logo field. +func (r *organizationResolver) Logo(ctx context.Context, obj *types.Organization) (*types.File, error) { + file, err := r.iam.OrganizationService.LogoFile(ctx, obj.ID) if err != nil { - r.logger.ErrorCtx(ctx, "cannot generate logo URL", log.Error(err)) + r.logger.ErrorCtx(ctx, "cannot load logo file", log.Error(err)) return nil, gqlutils.Internal(ctx) } - return presignedURL, nil + if file == nil { + return nil, nil + } + + return types.NewFile(file, r.baseURL), nil } -// HorizontalLogoURL is the resolver for the horizontalLogoUrl field. -func (r *organizationResolver) HorizontalLogoURL(ctx context.Context, obj *types.Organization) (*string, error) { - if _, err := r.authorize(ctx, obj.ID, iam.ActionOrganizationGet); err != nil { - return nil, err - } - - presignedURL, err := r.iam.OrganizationService.GenerateHorizontalLogoURL(ctx, obj.ID, 1*time.Hour) +// HorizontalLogo is the resolver for the horizontalLogo field. +func (r *organizationResolver) HorizontalLogo(ctx context.Context, obj *types.Organization) (*types.File, error) { + file, err := r.iam.OrganizationService.HorizontalLogoFile(ctx, obj.ID) if err != nil { - r.logger.ErrorCtx(ctx, "cannot generate horizontal logo URL", log.Error(err)) + r.logger.ErrorCtx(ctx, "cannot load horizontal logo file", log.Error(err)) return nil, gqlutils.Internal(ctx) } - return presignedURL, nil + if file == nil { + return nil, nil + } + + return types.NewFile(file, r.baseURL), nil } // Profiles is the resolver for the profiles field. diff --git a/pkg/server/api/connect/v1/types/file.go b/pkg/server/api/connect/v1/types/file.go new file mode 100644 index 000000000..44cdb8020 --- /dev/null +++ b/pkg/server/api/connect/v1/types/file.go @@ -0,0 +1,35 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package types + +import ( + "go.probo.inc/probo/pkg/baseurl" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/filemanager" +) + +func NewFile(r *coredata.File, base *baseurl.BaseURL) *File { + url := base.WithPath(filemanager.DownloadAPIPath(r)).MustString() + + return &File{ + ID: r.ID, + MimeType: r.MimeType, + FileName: r.FileName, + Size: r.FileSize, + DownloadURL: url, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } +} diff --git a/pkg/server/api/connect/v1/types/organization.go b/pkg/server/api/connect/v1/types/organization.go index 1140d42a0..362427815 100644 --- a/pkg/server/api/connect/v1/types/organization.go +++ b/pkg/server/api/connect/v1/types/organization.go @@ -23,7 +23,7 @@ type ( ) func NewOrganization(organization *coredata.Organization) *Organization { - return &Organization{ + org := &Organization{ ID: organization.ID, Name: organization.Name, Email: organization.Email, @@ -33,4 +33,14 @@ func NewOrganization(organization *coredata.Organization) *Organization { CreatedAt: organization.CreatedAt, UpdatedAt: organization.UpdatedAt, } + + if organization.LogoFileID != nil { + org.Logo = &File{ID: *organization.LogoFileID} + } + + if organization.HorizontalLogoFileID != nil { + org.HorizontalLogo = &File{ID: *organization.HorizontalLogoFileID} + } + + return org }