Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 51 additions & 14 deletions src/api/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthEnv>()
.get(
'/me',
Expand Down Expand Up @@ -275,9 +288,45 @@ const app = new Hono<AuthEnv>()
.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'),
Expand Down Expand Up @@ -305,13 +354,7 @@ const app = new Hono<AuthEnv>()
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))
Expand Down Expand Up @@ -422,13 +465,7 @@ const app = new Hono<AuthEnv>()
)
}

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))
Expand Down
122 changes: 105 additions & 17 deletions src/routes/[owner]/settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? '')

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -152,26 +210,56 @@ export default function OwnerSettings() {
</Alert>
)}

{isOwner ? (
<form onSubmit={handleUpdateProfile} className="mb-10 space-y-4">
<div className="mb-4 flex items-center gap-4">
{initialOrgData.avatarUrl ? (
<img
src={initialOrgData.avatarUrl}
alt="Avatar"
className="border-rule h-16 w-16 rounded-full border object-cover"
/>
) : (
<div className="bg-parchment-dark border-rule text-ink-muted flex h-16 w-16 items-center justify-center rounded-full border text-lg font-semibold">
{initialOrgData.displayName?.charAt(0)?.toUpperCase() ?? '?'}
</div>
)}
<div>
<p className="text-sm font-medium">{initialOrgData.displayName}</p>
<p className="text-ink-muted font-mono text-xs">@{owner}</p>
{isOwner && (
<div className="mb-8 flex items-start gap-4">
{avatarUrl ? (
<img
src={avatarUrl}
alt="Avatar"
className="border-rule h-16 w-16 rounded-full border object-cover"
/>
) : (
<div className="bg-parchment-dark border-rule text-ink-muted flex h-16 w-16 items-center justify-center rounded-full border text-lg font-semibold">
{initialOrgData.displayName?.charAt(0)?.toUpperCase() ?? '?'}
</div>
)}
<div className="min-w-0">
<p className="text-sm font-medium">{initialOrgData.displayName}</p>
<p className="text-ink-muted font-mono text-xs">@{owner}</p>
<form onSubmit={handleUploadAvatar} className="mt-2 flex flex-wrap items-center gap-2">
<input
type="file"
name="avatar"
accept="image/jpeg,image/png,image/gif,image/webp"
className="file:border-rule file:bg-parchment hover:file:bg-parchment-dark file:rounded-control text-xs file:mr-2 file:cursor-pointer file:border file:px-2.5 file:py-1 file:text-xs file:font-medium"
/>
<Button
type="submit"
size="sm"
variant="secondary"
disabled={submitting === 'avatar'}
>
{submitting === 'avatar' ? 'Uploading…' : 'Upload logo'}
</Button>
{avatarUrl && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={handleRemoveAvatar}
disabled={submitting === 'avatar'}
>
Remove
</Button>
)}
</form>
<p className="text-ink-muted mt-1 text-xs">JPEG, PNG, GIF or WebP, up to 5 MB.</p>
</div>
</div>
)}

{isOwner ? (
<form onSubmit={handleUpdateProfile} className="mb-10 space-y-4">
<div>
<label htmlFor="displayName" className="mb-1 block text-sm font-medium">
Display name
Expand Down
67 changes: 15 additions & 52 deletions src/routes/settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,6 @@ export default function Settings() {
const [website, setWebsite] = useState(currentUser?.website ?? '')
const [location, setLocation] = useState(currentUser?.location ?? '')

// Avatar
const [avatarUrl, setAvatarUrl] = useState(currentUser?.avatarUrl ?? '')

// Notifications
const notifPrefs = (currentUser?.notificationPrefs as Record<string, boolean>) ?? {}
const [collectionActivity, setCollectionActivity] = useState(
Expand Down Expand Up @@ -75,41 +72,6 @@ export default function Settings() {
}
}

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/me/avatar', {
method: 'POST',
credentials: 'include',
body: formData,
})
if (res.ok) {
const data = await res.json()
setSuccess('Avatar updated.')
setAvatarUrl(data.avatarUrl)
form.reset()
} else {
const body = await res.json().catch(() => ({}))
setError(body.error ?? 'Upload failed.')
}
} finally {
setSubmitting('')
}
}

async function handleUpdateNotifications(e: FormEvent) {
e.preventDefault()
clearMessages()
Expand Down Expand Up @@ -172,11 +134,11 @@ export default function Settings() {
</Alert>
)}

{/* Avatar */}
{/* Avatar — the picture comes from KF Auth, so it is changed there */}
<div className="mb-8 flex items-start gap-4">
{avatarUrl ? (
{currentUser.avatarUrl ? (
<img
src={avatarUrl}
src={currentUser.avatarUrl}
alt="Avatar"
className="border-rule h-16 w-16 rounded-full border object-cover"
/>
Expand All @@ -188,17 +150,18 @@ export default function Settings() {
<div className="min-w-0">
<p className="text-sm font-medium">{currentUser.displayName}</p>
<p className="text-ink-muted font-mono text-xs">@{currentUser.slug}</p>
<form onSubmit={handleUploadAvatar} className="mt-2 flex flex-wrap items-center gap-2">
<input
type="file"
name="avatar"
accept="image/jpeg,image/png,image/gif,image/webp"
className="file:border-rule file:bg-parchment hover:file:bg-parchment-dark file:rounded-control text-xs file:mr-2 file:cursor-pointer file:border file:px-2.5 file:py-1 file:text-xs file:font-medium"
/>
<Button type="submit" size="sm" variant="secondary" disabled={submitting === 'avatar'}>
{submitting === 'avatar' ? 'Uploading…' : 'Upload avatar'}
</Button>
</form>
<p className="text-ink-muted mt-2 text-xs">
Picture managed by your{' '}
<a
href={kfAccountUrl}
target="_blank"
rel="noopener noreferrer"
className="text-link hover:underline"
>
KF Account
</a>
.
</p>
</div>
</div>

Expand Down
Loading