Skip to content

Organization Avatar Upload Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add file upload support for organization avatars so owners/admins can upload a photo instead of only entering a URL.

Architecture: Add POST /v1/organizations/{orgId}/avatar endpoint in OrganizationHandler (mirrors existing POST /v1/users/me/avatar). Inject *Config into OrganizationHandler (it currently has no config access). Re-use the same AvatarDir config field and validateAvatarFile helper. On the frontend, upgrade the URL-only input in settings.tsx to a full avatar widget (click-to-upload + URL fallback + Remove button) matching the pattern in PreferencesSettings.tsx.

Tech Stack: Go/chi router, multipart upload, Vitest (frontend), React 18 + TanStack Query


Task 1: Inject config into OrganizationHandler (backend)

Files:

  • Modify: internal/controlplane/api/organization_handler.go lines 18-50
  • Modify: internal/controlplane/di/container.go line 731

Step 1: Add config field and update constructor

In organization_handler.go, add config *Config to the struct and constructor:

go
type OrganizationHandler struct {
    orgRepo      *store.OrganizationRepo
    memberRepo   *store.OrganizationMemberRepo
    subRepo      *store.SubscriptionRepo
    planRepo     *store.PlanRepo
    auditRepo    *store.AuditRepo
    limitChecker *billing.LimitChecker
    permChecker  *auth.PermissionChecker
    logger       *zap.Logger
    config       *Config  // ADD THIS
}

func NewOrganizationHandler(
    orgRepo *store.OrganizationRepo,
    memberRepo *store.OrganizationMemberRepo,
    subRepo *store.SubscriptionRepo,
    planRepo *store.PlanRepo,
    auditRepo *store.AuditRepo,
    limitChecker *billing.LimitChecker,
    permChecker *auth.PermissionChecker,
    logger *zap.Logger,
    config *Config,  // ADD THIS
) *OrganizationHandler {
    return &OrganizationHandler{
        orgRepo:      orgRepo,
        memberRepo:   memberRepo,
        subRepo:      subRepo,
        planRepo:     planRepo,
        auditRepo:    auditRepo,
        limitChecker: limitChecker,
        permChecker:  permChecker,
        logger:       logger,
        config:       config,  // ADD THIS
    }
}

Step 2: Update DI container call

In di/container.go line 731, add cfg as last argument:

go
c.Handlers.Organization = api.NewOrganizationHandler(
    c.Repos.Organization, c.Repos.OrganizationMember,
    c.Repos.Subscription, c.Repos.Plan,
    c.Repos.Audit, c.Services.LimitChecker,
    c.Services.PermissionChecker, logger, cfg,
)

Note: Read the exact current call first with grep -n "NewOrganizationHandler" internal/controlplane/di/container.go.

Step 3: Verify build

bash
go build ./...

Expected: no errors.

Step 4: Commit

bash
git add internal/controlplane/api/organization_handler.go internal/controlplane/di/container.go
git commit -m "feat: inject config into OrganizationHandler"

Task 2: Add UploadAvatar + ServeAvatar handlers to OrganizationHandler (backend)

Files:

  • Modify: internal/controlplane/api/organization_handler.go (append new methods)

Step 1: Add UploadOrgAvatar handler

Append to organization_handler.go. The pattern is identical to UserHandler.UploadAvatar in user_handler.go. Key differences:

  • Reads {orgId} from URL param instead of currentUser.UserID
  • Checks membership role (owner or admin only) before accepting upload
  • Calls h.orgRepo.Update(ctx, orgID, org.Name, &avatarURL) to persist
  • Serves avatar at /v1/organizations/{orgId}/avatar
go
// UploadOrgAvatar handles POST /v1/organizations/{orgId}/avatar
func (h *OrganizationHandler) UploadOrgAvatar(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    user := auth.GetUserFromContext(ctx)
    if user == nil {
        RespondWithAPIError(w, NewAPIError(ErrCodeUnauthorized, "unauthorized"))
        return
    }

    orgID, err := uuid.Parse(chi.URLParam(r, "orgId"))
    if err != nil {
        RespondWithAPIError(w, ValidationError("invalid organization ID", nil))
        return
    }

    // Check admin/owner permission
    role, err := h.memberRepo.GetUserRole(ctx, orgID, user.UserID)
    if err != nil {
        RespondWithAPIError(w, NewAPIError(ErrCodeForbidden, "not a member of this organization"))
        return
    }
    if role != store.OrgRoleOwner && role != store.OrgRoleAdmin {
        RespondWithAPIError(w, NewAPIError(ErrCodeForbidden, "admin permission required"))
        return
    }

    if err := r.ParseMultipartForm(10 << 20); err != nil {
        RespondWithAPIError(w, ValidationError("failed to parse form", nil))
        return
    }

    file, header, err := r.FormFile("avatar")
    if err != nil {
        RespondWithAPIError(w, ValidationError("avatar field is required", nil))
        return
    }
    defer file.Close()

    mimeType, ext, err := validateAvatarFile(file, header)
    if err != nil {
        RespondWithAPIError(w, ValidationError(err.Error(), nil))
        return
    }
    _ = mimeType

    avatarDir := "/var/lib/shardlyn/avatars/orgs"
    if h.config != nil && h.config.AvatarDir != "" {
        avatarDir = h.config.AvatarDir + "/orgs"
    }

    if err := os.MkdirAll(avatarDir, 0750); err != nil {
        h.logger.Error("failed to create org avatar directory", zap.Error(err))
        RespondWithAPIError(w, NewAPIError(ErrCodeInternal, "failed to create avatar directory"))
        return
    }

    filename := orgID.String() + "." + ext
    destPath := filepath.Join(avatarDir, filename)

    dest, err := os.Create(destPath)
    if err != nil {
        h.logger.Error("failed to create org avatar file", zap.Error(err))
        RespondWithAPIError(w, NewAPIError(ErrCodeInternal, "failed to save avatar"))
        return
    }
    defer dest.Close()

    if _, err := file.Seek(0, 0); err != nil {
        RespondWithAPIError(w, NewAPIError(ErrCodeInternal, "failed to read avatar"))
        return
    }
    if _, err := io.Copy(dest, file); err != nil {
        h.logger.Error("failed to write org avatar file", zap.Error(err))
        RespondWithAPIError(w, NewAPIError(ErrCodeInternal, "failed to save avatar"))
        return
    }

    // Build serving URL
    baseURL := r.Header.Get("X-Forwarded-Proto") + "://" + r.Host
    if r.Header.Get("X-Forwarded-Proto") == "" {
        scheme := "http"
        if r.TLS != nil {
            scheme = "https"
        }
        baseURL = scheme + "://" + r.Host
    }
    avatarURL := fmt.Sprintf("%s/v1/organizations/%s/avatar", baseURL, orgID.String())

    org, err := h.orgRepo.GetByID(ctx, orgID)
    if err != nil {
        RespondWithAPIError(w, NewAPIError(ErrCodeInternal, "failed to fetch organization"))
        return
    }
    if err := h.orgRepo.Update(ctx, orgID, org.Name, &avatarURL); err != nil {
        h.logger.Error("failed to update org avatar url", zap.Error(err))
        RespondWithAPIError(w, NewAPIError(ErrCodeInternal, "failed to update avatar"))
        return
    }

    respondOK(w, map[string]string{"avatar_url": avatarURL})
}

// ServeOrgAvatar handles GET /v1/organizations/{orgId}/avatar
func (h *OrganizationHandler) ServeOrgAvatar(w http.ResponseWriter, r *http.Request) {
    orgID := chi.URLParam(r, "orgId")

    avatarDir := "/var/lib/shardlyn/avatars/orgs"
    if h.config != nil && h.config.AvatarDir != "" {
        avatarDir = h.config.AvatarDir + "/orgs"
    }

    extensions := []string{"jpg", "jpeg", "png", "webp", "gif"}
    for _, ext := range extensions {
        path := filepath.Join(avatarDir, orgID+"."+ext)
        if _, err := os.Stat(path); err == nil {
            http.ServeFile(w, r, path)
            return
        }
    }

    http.NotFound(w, r)
}

Note: Ensure imports include "io", "fmt", "os", "path/filepath" — check existing imports at top of file.

Step 2: Verify build

bash
go build ./...

Expected: no errors.

Step 3: Commit

bash
git add internal/controlplane/api/organization_handler.go
git commit -m "feat: add UploadOrgAvatar and ServeOrgAvatar handlers"

Task 3: Register new routes in router (backend)

Files:

  • Modify: internal/controlplane/api/router.go (inside /organizations/{orgId} route block, around line 928)

Step 1: Add routes

Inside the r.Route("/{orgId}", ...) block, after the existing r.With(requirePerm("org.update")).Put("/", ...) line, add:

go
r.With(requirePerm("org.update")).Post("/avatar", handler.organizationHandler.UploadOrgAvatar)
r.Get("/avatar", handler.organizationHandler.ServeOrgAvatar)

Step 2: Verify build

bash
go build ./...

Expected: no errors.

Step 3: Run tests

bash
go test ./internal/controlplane/...

Expected: all pass.

Step 4: Commit

bash
git add internal/controlplane/api/router.go
git commit -m "feat: register org avatar upload and serve routes"

Task 4: Add uploadOrgAvatar to frontend API client

Files:

  • Modify: web/src/api/organizations.ts

Step 1: Add uploadOrgAvatar function

Append to the organizationsApi object in web/src/api/organizations.ts:

ts
uploadAvatar: async (orgId: string, file: File): Promise<{ avatar_url: string }> => {
  const token = useAuthStore.getState().token
  const formData = new FormData()
  formData.append('avatar', file)

  const csrfMatch = document.cookie.match(/shardlyn_csrf_token=([^;]+)/)
  const csrfToken = csrfMatch ? csrfMatch[1] : null

  const headers: Record<string, string> = {}
  if (token) headers['Authorization'] = `Bearer ${token}`
  if (csrfToken) headers['X-CSRF-Token'] = csrfToken

  const API_BASE = import.meta.env.VITE_API_BASE ?? ''
  const response = await fetch(`${API_BASE}/v1/organizations/${orgId}/avatar`, {
    method: 'POST',
    headers,
    body: formData,
    credentials: 'include',
  })

  if (!response.ok) {
    const body = await response.json().catch(() => ({}))
    throw new ApiError(
      response.status,
      body?.error?.code || 'UNKNOWN',
      body?.error?.message || 'Failed to upload avatar',
    )
  }

  return response.json()
},

Also add these imports at the top of organizations.ts if not present:

ts
import { useAuthStore } from '../stores/auth'
import { ApiError } from './request'

Step 2: Verify typecheck

bash
cd web && npm run typecheck

Expected: no errors.

Step 3: Commit

bash
git add web/src/api/organizations.ts
git commit -m "feat: add uploadAvatar to organizationsApi"

Task 5: Upgrade org settings avatar UI

Files:

  • Modify: web/src/pages/organizations/settings.tsx (avatar section ~lines 280-305)

Step 1: Read the current avatar section

Read web/src/pages/organizations/settings.tsx lines 60-110 (state) and 280-310 (avatar UI).

Step 2: Add state and mutation

At the top of the component, add:

  • const fileInputRef = useRef<HTMLInputElement>(null) (add useRef to React imports)
  • const [avatarPreview, setAvatarPreview] = useState<string | null>(org.avatar_url || null) — update the existing avatarUrl state initialization too

Add an uploadAvatar mutation after the existing updateOrg mutation:

ts
const uploadAvatar = useMutation({
  mutationFn: (file: File) => organizationsApi.uploadAvatar(org.id, file),
  onSuccess: (data) => {
    const newUrl = data.avatar_url
    setAvatarUrl(newUrl)
    setAvatarPreview(newUrl)
    queryClient.invalidateQueries({ queryKey: ['organization', orgId] })
    toast.success(t('org.settings.avatarUploadSuccess', { defaultValue: 'Avatar updated' }))
  },
  onError: () => {
    toast.error(t('org.settings.avatarUploadFailed', { defaultValue: 'Failed to upload avatar' }))
  },
})

Add file input handler:

ts
const MAX_FILE_SIZE = 2 * 1024 * 1024
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']

const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  const file = e.target.files?.[0]
  if (!file) return
  if (!ALLOWED_TYPES.includes(file.type)) {
    toast.error(t('settings.profile.fileTypeError'))
    return
  }
  if (file.size > MAX_FILE_SIZE) {
    toast.error(t('settings.profile.fileSizeError'))
    return
  }
  const objectUrl = URL.createObjectURL(file)
  setAvatarPreview(objectUrl)
  uploadAvatar.mutate(file)
  e.target.value = ''
}

Step 3: Replace avatar UI block

Replace the current avatar section (the <div className="flex items-center gap-6"> block containing the avatar and URL input) with:

tsx
{/* Avatar */}
<div className="flex flex-col sm:flex-row gap-6 items-start">
  {/* Avatar preview - click to upload */}
  <div className="flex-shrink-0">
    <div className="relative group">
      <div
        className={`w-20 h-20 rounded-2xl flex items-center justify-center text-2xl font-bold text-white shadow-lg overflow-hidden cursor-pointer ${!avatarPreview ? 'bg-gradient-to-br ' + getAvatarGradient(name || org.name) : ''}`}
        onClick={() => isOwner && fileInputRef.current?.click()}
        title={t('settings.profile.uploadPhoto')}
      >
        {avatarPreview ? (
          <img
            src={avatarPreview}
            alt={name || org.name}
            className="w-full h-full object-cover"
            onError={() => setAvatarPreview(null)}
          />
        ) : (
          getInitials(name || org.name)
        )}
        {isOwner && (
          <div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center rounded-2xl">
            {uploadAvatar.isPending ? (
              <Loader2 className="w-5 h-5 text-white animate-spin" />
            ) : (
              <Upload className="w-5 h-5 text-white" />
            )}
          </div>
        )}
      </div>
    </div>
    <input
      ref={fileInputRef}
      type="file"
      accept="image/jpeg,image/png,image/webp,image/gif"
      className="hidden"
      onChange={handleFileChange}
      disabled={!isOwner}
    />
  </div>

  {/* Controls */}
  <div className="flex-1 space-y-3">
    <p className="text-sm font-medium text-[color:var(--text)]">
      {t('org.settings.avatarUrl', { defaultValue: 'Avatar' })}
    </p>
    <p className="text-xs text-muted">
      {t('org.settings.avatarHint', { defaultValue: 'Optional. Leave blank to use a generated color avatar.' })}
    </p>

    {isOwner && (
      <div className="flex flex-wrap gap-2">
        <button
          type="button"
          onClick={() => fileInputRef.current?.click()}
          disabled={uploadAvatar.isPending}
          className="btn btn-secondary btn-sm flex items-center gap-1.5"
        >
          {uploadAvatar.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
          {t('settings.profile.uploadPhoto')}
        </button>

        {avatarPreview && (
          <button
            type="button"
            onClick={() => { setAvatarUrl(''); setAvatarPreview(null) }}
            className="btn btn-ghost btn-sm flex items-center gap-1.5 text-red-500 hover:text-red-600"
          >
            <X className="w-3.5 h-3.5" />
            {t('settings.profile.removePhoto')}
          </button>
        )}
      </div>
    )}

    {/* URL input fallback */}
    <input
      type="url"
      id="org-avatar"
      value={avatarUrl}
      onChange={(e) => {
        setAvatarUrl(e.target.value)
        setAvatarPreview(e.target.value || null)
      }}
      className="input text-sm w-full"
      placeholder="https://example.com/logo.png"
      disabled={!isOwner}
    />
  </div>
</div>

Step 4: Add missing imports

Ensure these are imported at the top of settings.tsx:

  • useRef from react
  • Upload, X, Loader2 from lucide-react
  • organizationsApi (already imported via the org query calls — verify)
  • useMutation from @tanstack/react-query (already imported — verify)

Step 5: Update avatarPreview sync in useEffect

In the existing useEffect that sets form state from org data, add:

ts
setAvatarPreview(org.avatar_url || null)

Step 6: Verify typecheck and lint

bash
cd web && npm run typecheck && npm run lint

Expected: no errors.

Step 7: Commit

bash
git add web/src/pages/organizations/settings.tsx web/src/api/organizations.ts
git commit -m "feat: add org avatar file upload to settings UI"

Task 6: Final verification

Step 1: Full build

bash
go build ./... && cd web && npm run build

Expected: both succeed.

Step 2: Run all tests

bash
go test ./internal/controlplane/... && cd web && npm run test -- --run

Expected: all pass.

Step 3: Commit if any fixups needed, then done

Built for teams that want control of their own infrastructure.