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.golines 18-50 - Modify:
internal/controlplane/di/container.goline 731
Step 1: Add config field and update constructor
In organization_handler.go, add config *Config to the struct and constructor:
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:
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
go build ./...Expected: no errors.
Step 4: Commit
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 ofcurrentUser.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
// 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
go build ./...Expected: no errors.
Step 3: Commit
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:
r.With(requirePerm("org.update")).Post("/avatar", handler.organizationHandler.UploadOrgAvatar)
r.Get("/avatar", handler.organizationHandler.ServeOrgAvatar)Step 2: Verify build
go build ./...Expected: no errors.
Step 3: Run tests
go test ./internal/controlplane/...Expected: all pass.
Step 4: Commit
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:
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:
import { useAuthStore } from '../stores/auth'
import { ApiError } from './request'Step 2: Verify typecheck
cd web && npm run typecheckExpected: no errors.
Step 3: Commit
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)(adduseRefto React imports)const [avatarPreview, setAvatarPreview] = useState<string | null>(org.avatar_url || null)— update the existingavatarUrlstate initialization too
Add an uploadAvatar mutation after the existing updateOrg mutation:
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:
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:
{/* 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:
useReffromreactUpload, X, Loader2fromlucide-reactorganizationsApi(already imported via the org query calls — verify)useMutationfrom@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:
setAvatarPreview(org.avatar_url || null)Step 6: Verify typecheck and lint
cd web && npm run typecheck && npm run lintExpected: no errors.
Step 7: Commit
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
go build ./... && cd web && npm run buildExpected: both succeed.
Step 2: Run all tests
go test ./internal/controlplane/... && cd web && npm run test -- --runExpected: all pass.
Step 3: Commit if any fixups needed, then done