Upload, access and delete files
Reuse the real avatar and logo actions, including validation, private reads and cleanup.
File map
| Path | Responsibility |
|---|---|
app/actions/account.ts | saveAvatar, password/account lifecycle and avatar cleanup |
app/actions/workspace-logo.ts | saveWorkspaceLogo validation and manager check |
lib/auth.ts | Profile/workspace reads and one-hour avatar signed URLs |
app/api/workspaces/[id]/logo/route.ts | Authenticated workspace-logo response |
supabase/migrations | Bucket, path policies, profile constraints and logo schema/RPC |
Start with Storage overview so you do not confuse a personal Storage object with a database-backed workspace logo.
Upload a personal avatar
The existing server action takes FormData with a photo File. This complete client example calls the shipped action and displays its actual result:
'use client';
import { useState, useTransition } from 'react';
import { saveAvatar } from '@/app/actions/account';
export function AvatarExample() {
const [message, setMessage] = useState('');
const [pending, startTransition] = useTransition();
return (
<form action={form => startTransition(async () => {
const result = await saveAvatar(form);
setMessage(result.error || result.success || 'No change was reported.');
})}>
<label htmlFor="avatar-photo">Profile photo</label>
<input id="avatar-photo" name="photo" type="file"
accept="image/jpeg,image/png,image/webp" required />
<button disabled={pending} type="submit">{pending ? 'Uploading…' : 'Upload photo'}</button>
<p role="status">{message}</p>
</form>
);
}This is an alternative presentation of the existing feature, not a second backend. Do not replace saveAvatar with a browser call using the secret key. The action gets the current user, derives <user-id>/avatar.webp, checks size/type, decodes with Sharp and uploads normalized WebP with upsert: true. It then updates that user's profile path and revalidates the dashboard layout.
Client file restrictions help the picker; the server still needs validation. A MIME string can be forged, decoding can fail and tiny compressed input can contain too many pixels. Keep the existing 2 MB and decoded-pixel bounds.
Read a private avatar
Use the current user's authorized client to create a signed URL. This server helper mirrors the installed path and expiry:
import { requireUser } from '@/lib/auth';
export async function readMyAvatarUrl() {
const { user, supabase } = await requireUser();
const result = await supabase.storage
.from('avatars')
.createSignedUrl(`${user.id}/avatar.webp`, 3600);
if (result.error) return null;
return result.data.signedUrl;
}An absent object can return no URL. A signed URL is temporary access, not a permanent public asset path. Avoid logging it or embedding it in public MDX. The existing getWorkspace() and public-account presentation already return a signed avatar URL when a saved path exists; prefer those existing results where appropriate.
Replace or remove an avatar
A replacement uses the same canonical path and overwrites through the user's permitted Storage operation. Removal calls the same server action with the explicit flag:
const form = new FormData();
form.set('remove', 'true');
const result = await saveAvatar(form);Use this inside a client event/action that imports saveAvatar as in the upload example, and display result.error or result.success. The action removes the object, then clears profiles.avatar_path. If Storage succeeds but profile cleanup fails, it reports partial progress and supports retry. Do not claim that nothing changed after such an error.
Upload and read a workspace logo
The logo action expects org_id plus a logo File, or remove=true. Use the explicit workspace ID from the form's rendered context. The server verifies current owner/admin membership; a Member cannot write even if it forges the request.
import { saveWorkspaceLogo } from '@/app/actions/workspace-logo';
export async function uploadLogo(workspaceId: string, file: File) {
const form = new FormData();
form.set('org_id', workspaceId);
form.set('logo', file);
return saveWorkspaceLogo(form);
}The normalized image is passed to the existing set_workspace_logo RPC, which enforces the database boundary. The logo URL is supplied by workspace state and includes its version. The route serves only verified members and does not use a public Storage URL or a cached cross-user response.
To remove it, pass org_id and remove=true to the same action. A stale tab keeps its original workspace target. Verify that switching workspace clears upload feedback and does not send a file to the wrong tenant.
Membership loss and lifecycle cleanup
When membership ends, subsequent authenticated logo access is denied. Workspace deletion cascades atomically to database logos. Avatar objects need explicit Storage API cleanup before Auth deletion; database foreign keys alone do not delete remote objects.
Adding another bucket requires adding cleanup before account deletion and deciding how workspace deletion removes its objects. Handle partial failures explicitly. Do not weaken shared-workspace ownership or billing deletion guards to simplify file cleanup.
Larger files and verification
For documents or larger uploads, design new size/type bounds, content handling, tenancy paths and cleanup. A signed-upload flow may be appropriate, but it is not included in this image settings feature. Do not advertise arbitrary files or malware scanning without implementing and testing those capabilities.
Verify wrong-account avatar paths, Member/nonmember logo writes, invalid MIME/bytes, large dimensions, replacement, remove, reload and deletion cleanup. Compare signed avatar lifetime with immediate authenticated logo rechecks. No live uploads or deletions were performed for this manual; use the template's dedicated hosted storage/browser checks before releasing a storage change.
Template 0.0.1 · September 2026 source snapshot