Migrate Connect org logos to File type

Replace Organization.logoUrl and horizontalLogoUrl with nested File
objects whose downloadUrl points at /api/files/v1/public/{id}, matching
the Console migration.

Org logos are FileVisibilityPublic and served without HTTP auth, so
Connect File.downloadUrl is built eagerly in NewFile with no field-level
authorize. Logo loading moves to iam.OrganizationService.LogoFile and
HorizontalLogoFile; the old URL generators are removed.

Sync IAM Relay components and n8n organization operations. Add an e2e
test for Connect multipart logo upload and ExecuteConnectWithFile.

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
Ludovic Vielle
2026-06-10 16:19:45 +02:00
parent c7e2fc209f
commit e06f3e0520
17 changed files with 315 additions and 84 deletions

View File

@@ -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) {
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 hover:text-primary flex-1">
<Avatar
src={organization.logoUrl}
src={organization.logo?.downloadUrl}
name={organization.name}
size="l"
/>

View File

@@ -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 (
<DropdownItem key={id} asChild>
<Link to={`/organizations/${organization.id}`}>
<Avatar name={organization.name} src={organization.logoUrl} />
<Avatar name={organization.name} src={organization.logo?.downloadUrl} />
<span className="flex-1">{organization.name}</span>
{isAssuming && (
<IconCheckmark1 size={16} className="text-green-600" />

View File

@@ -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: {
<Label>{__("Organization logo")}</Label>
<div className="flex w-max items-center gap-4">
<Avatar
className={logoPreview || organization.logoUrl ? "bg-transparent" : undefined}
src={logoPreview || organization.logoUrl}
className={logoPreview || organization.logo?.downloadUrl ? "bg-transparent" : undefined}
src={logoPreview || organization.logo?.downloadUrl}
name={organization.name}
size="xl"
/>
@@ -253,12 +263,12 @@ export function OrganizationForm(props: {
)}
</p>
<div className="flex items-center gap-4">
{(horizontalLogoPreview || organization.horizontalLogoUrl) && (
{(horizontalLogoPreview || organization.horizontalLogo?.downloadUrl) && (
<div className="border border-border-solid rounded-md p-4 bg-surface-secondary">
<img
src={
horizontalLogoPreview
|| organization.horizontalLogoUrl
|| organization.horizontalLogo?.downloadUrl
|| undefined
}
alt={__("Horizontal logo")}
@@ -275,12 +285,12 @@ export function OrganizationForm(props: {
>
{isUpdatingOrganization
? __("Uploading...")
: horizontalLogoPreview || organization.horizontalLogoUrl
: horizontalLogoPreview || organization.horizontalLogo?.downloadUrl
? __("Change horizontal logo")
: __("Upload horizontal logo")}
</FileButton>
)}
{canUpdate && organization.horizontalLogoUrl && (
{canUpdate && organization.horizontalLogo?.downloadUrl && (
<Dialog
ref={deleteDialogRef}
trigger={(

View File

@@ -0,0 +1,136 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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,
)
}

View File

@@ -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)
}

View File

@@ -48,8 +48,16 @@ export async function execute(
websiteUrl
email
headquarterAddress
logoUrl
horizontalLogoUrl
logo {
id
fileName
downloadUrl
}
horizontalLogo {
id
fileName
downloadUrl
}
createdAt
updatedAt
}

View File

@@ -48,8 +48,16 @@ export async function execute(
websiteUrl
email
headquarterAddress
logoUrl
horizontalLogoUrl
logo {
id
fileName
downloadUrl
}
horizontalLogo {
id
fileName
downloadUrl
}
createdAt
updatedAt
}

View File

@@ -68,8 +68,16 @@ export async function execute(
websiteUrl
email
headquarterAddress
logoUrl
horizontalLogoUrl
logo {
id
fileName
downloadUrl
}
horizontalLogo {
id
fileName
downloadUrl
}
createdAt
updatedAt
}

View File

@@ -119,8 +119,16 @@ export async function execute(
websiteUrl
email
headquarterAddress
logoUrl
horizontalLogoUrl
logo {
id
fileName
downloadUrl
}
horizontalLogo {
id
fileName
downloadUrl
}
createdAt
updatedAt
}

View File

@@ -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(

View File

@@ -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"

View File

@@ -11,6 +11,7 @@ directive @goModel(
directive @goEnum(value: String) on ENUM_VALUE
scalar BigInt
scalar CursorKey
scalar Datetime
scalar Upload

View File

@@ -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!
}

View File

@@ -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

View File

@@ -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.

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// 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,
}
}

View File

@@ -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
}