diff --git a/src/api/accounts.ts b/src/api/accounts.ts index 7476514..851c973 100644 --- a/src/api/accounts.ts +++ b/src/api/accounts.ts @@ -37,6 +37,19 @@ async function requireOrgMembership( return membership } +/** + * Delete an org's stored avatar files, except `keepKey`. Non-fatal: an + * orphaned avatar is harmless, so storage errors are logged, not thrown. + */ +async function deleteOrgAvatars(orgId: string, keepKey?: string) { + try { + const keys = (await listPublicAssets(`avatars/${orgId}/`)).filter((k) => k !== keepKey) + if (keys.length > 0) await deletePublicAssets(keys) + } catch (err) { + console.error(`[accounts] Failed to delete avatars for org ${orgId}:`, err) + } +} + const app = new Hono() .get( '/me', @@ -275,9 +288,45 @@ const app = new Hono() .set({ avatarUrl: `${ASSETS_BASE_URL}/${key}` }) .where(eq(schema.organization.id, org.id)) + await deleteOrgAvatars(org.id, key) + return c.json({ ok: true, avatarUrl: `${ASSETS_BASE_URL}/${key}` }) }, ) + .delete( + '/:slug/avatar', + requireAuth('write'), + requireUnscopedKey(), + openApi({ + tags: ['Accounts'], + summary: 'Remove organization avatar', + request: { param: z.object({ slug: z.string() }) }, + responses: { 200: z.any() }, + }), + async (c) => { + const { slug } = c.req.valid('param') + const userId = c.get('userId')! + + const org = await findOrgBySlug(slug) + if (!org) return c.json({ error: 'Organization not found', statusCode: 404 }, 404) + + if (!(await requireOrgMembership(org.id, userId, 'owner'))) { + return c.json( + { error: 'Must be an owner to update the organization avatar', statusCode: 403 }, + 403, + ) + } + + await db + .update(schema.organization) + .set({ avatarUrl: null }) + .where(eq(schema.organization.id, org.id)) + + await deleteOrgAvatars(org.id) + + return c.json({ ok: true }) + }, + ) .delete( '/me', requireAuth('write'), @@ -305,13 +354,7 @@ const app = new Hono() return c.json({ error: 'Username confirmation does not match', statusCode: 422 }, 422) } - try { - const avatarKeys = await listPublicAssets(`avatars/${defaultOrg.id}/`) - if (avatarKeys.length > 0) await deletePublicAssets(avatarKeys) - } catch (err) { - // Non-fatal — orphaned avatars are harmless - console.error(`[accounts] Failed to delete avatars for org ${defaultOrg.id}:`, err) - } + await deleteOrgAvatars(defaultOrg.id) await db.delete(schema.apikey).where(eq(schema.apikey.referenceId, userId)) await db.delete(schema.apikey).where(eq(schema.apikey.referenceId, defaultOrg.id)) @@ -422,13 +465,7 @@ const app = new Hono() ) } - try { - const avatarKeys = await listPublicAssets(`avatars/${org.id}/`) - if (avatarKeys.length > 0) await deletePublicAssets(avatarKeys) - } catch (err) { - // Non-fatal — orphaned avatars are harmless - console.error(`[accounts] Failed to delete avatars for org ${org.id}:`, err) - } + await deleteOrgAvatars(org.id) await db.delete(schema.apikey).where(eq(schema.apikey.referenceId, org.id)) await db.delete(schema.invitation).where(eq(schema.invitation.organizationId, org.id)) diff --git a/src/routes/[owner]/settings/index.tsx b/src/routes/[owner]/settings/index.tsx index 483b3fe..2f87714 100644 --- a/src/routes/[owner]/settings/index.tsx +++ b/src/routes/[owner]/settings/index.tsx @@ -28,6 +28,9 @@ export default function OwnerSettings() { const [website, setWebsite] = useState(initialOrgData.website ?? '') const [location, setLocation] = useState(initialOrgData.location ?? '') + // Avatar + const [avatarUrl, setAvatarUrl] = useState(initialOrgData.avatarUrl ?? '') + // KF org link const [kfOrgId, setKfOrgId] = useState(initialOrgData.kfOrgId ?? '') @@ -81,6 +84,61 @@ export default function OwnerSettings() { } } + async function handleUploadAvatar(e: FormEvent) { + e.preventDefault() + clearMessages() + + const form = e.target as HTMLFormElement + const fileInput = form.elements.namedItem('avatar') as HTMLInputElement + const file = fileInput?.files?.[0] + if (!file) { + setError('Please select an image file.') + return + } + + setSubmitting('avatar') + try { + const formData = new FormData() + formData.append('avatar', file) + const res = await fetch(`/api/accounts/${owner}/avatar`, { + method: 'POST', + credentials: 'include', + body: formData, + }) + if (res.ok) { + const data = await res.json() + setSuccess('Logo updated.') + setAvatarUrl(data.avatarUrl) + form.reset() + } else { + const body = await res.json().catch(() => ({})) + setError(body.error ?? 'Upload failed.') + } + } finally { + setSubmitting('') + } + } + + async function handleRemoveAvatar() { + clearMessages() + setSubmitting('avatar') + try { + const res = await fetch(`/api/accounts/${owner}/avatar`, { + method: 'DELETE', + credentials: 'include', + }) + if (res.ok) { + setSuccess('Logo removed.') + setAvatarUrl('') + } else { + const body = await res.json().catch(() => ({})) + setError(body.error ?? 'Failed to remove logo.') + } + } finally { + setSubmitting('') + } + } + async function handleUpdateArk(e: FormEvent) { e.preventDefault() clearMessages() @@ -152,26 +210,56 @@ export default function OwnerSettings() { )} - {isOwner ? ( -
-
- {initialOrgData.avatarUrl ? ( - Avatar - ) : ( -
- {initialOrgData.displayName?.charAt(0)?.toUpperCase() ?? '?'} -
- )} -
-

{initialOrgData.displayName}

-

@{owner}

+ {isOwner && ( +
+ {avatarUrl ? ( + Avatar + ) : ( +
+ {initialOrgData.displayName?.charAt(0)?.toUpperCase() ?? '?'}
+ )} +
+

{initialOrgData.displayName}

+

@{owner}

+ + + + {avatarUrl && ( + + )} + +

JPEG, PNG, GIF or WebP, up to 5 MB.

+
+ )} + {isOwner ? ( +