User Profile Settings Page - Design Document
User Profile Settings Page - Design Document
Date: 2026-02-11
Status: Design Approved
Author: OpenCode
Overview
This document outlines the design for a user profile settings page in the dashboard that allows users to:
- Change their username
- Update their avatar (profile image)
- Delete their account with proper cascading deletion
Table of Contents
- Architecture
- Storage Strategy
- API Layer
- Frontend Components
- Cascade Deletion Logic
- Navigation Integration
- Dependencies
- Security Considerations
Architecture
Route Structure
Primary Route: /dashboard/profile
/app/dashboard/profile/
├── page.tsx # Main settings page (server component)
└── _components/
├── username-form.tsx # Username change form (client component)
├── avatar-form.tsx # Avatar upload form (client component)
└── delete-account-form.tsx # Account deletion (client component)
Page Layout
Single-page design with three vertically stacked sections:
-
Profile Information (top)
- Username update form
- Validation: 3-30 chars, alphanumeric + hyphens/underscores
- Real-time availability check
-
Avatar (middle)
- Image upload with live preview
- File constraints: 5MB max, JPG/PNG/GIF/WebP
- Auto-processing: resize to 512x512, convert to WebP
-
Danger Zone (bottom)
- Red-bordered warning section
- Account deletion with double confirmation
- Shows statistics of what will be deleted
Storage Strategy
R2 Bucket Structure
Following the existing pattern where site files use {siteId}/{branch}/raw/{path}, we introduce parallel prefixes for general assets:
# Existing: Site content files
{siteId}/{branch}/raw/{path}
# New: User assets
users/{userId}/avatar.webp
# Future: Publication assets
publications/{publicationId}/avatar.webp
publications/{publicationId}/og-image.webp
# Future: Site featured images
sites/{siteId}/featured-image.webp
Storage Rationale
- Flat structure at root level: Matches existing site file pattern
- Single avatar policy: New upload replaces old one (simple, no versioning)
- Predictable naming:
avatar.webp(always WebP after processing) - Easy cleanup: Delete entire
users/{userId}/prefix on account deletion
URL Generation
Development: http://localhost:9000/datahub/users/{userId}/avatar.webp
Staging: https://staging-r2.datahub.io/users/{userId}/avatar.webp
Production: https://r2.datahub.io/users/{userId}/avatar.webp
Stored in User.image field as full URL.
API Layer
New tRPC Endpoints
File: server/api/routers/user.ts
1. Update Username
updateUsername: protectedProcedure
.input(z.object({
username: z.string()
.min(3, "Username must be at least 3 characters")
.max(30, "Username must be at most 30 characters")
.regex(/^[a-zA-Z0-9_-]+$/, "Only letters, numbers, hyphens, and underscores")
}))
.mutation(async ({ ctx, input }) => {
// Check uniqueness
const existing = await ctx.db.user.findUnique({
where: { username: input.username }
});
if (existing && existing.id !== ctx.session.user.id) {
throw new TRPCError({ code: "CONFLICT", message: "Username already taken" });
}
// Update
return ctx.db.user.update({
where: { id: ctx.session.user.id },
data: { username: input.username }
});
})
2. Get Account Statistics
getAccountStats: protectedProcedure
.query(async ({ ctx }) => {
const userId = ctx.session.user.id;
const publicationsCount = await ctx.db.publication.count({
where: { ownerId: userId }
});
const ownedSitesCount = await ctx.db.site.count({
where: { publication: { ownerId: userId } }
});
const contributedSitesCount = await ctx.db.siteAuthor.count({
where: {
userId,
site: { publication: { ownerId: { not: userId } } }
}
});
return { publicationsCount, ownedSitesCount, contributedSitesCount };
})
3. Delete Account
deleteAccount: protectedProcedure
.input(z.object({
confirmUsername: z.string()
}))
.mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
const user = await ctx.db.user.findUnique({
where: { id: userId },
select: {
username: true,
publications: { select: { id: true, sites: { select: { id: true } } } }
}
});
// Verify confirmation
if (user.username !== input.confirmUsername) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Username doesn't match" });
}
// Collect site IDs for R2 cleanup
const siteIds = user.publications.flatMap(pub => pub.sites.map(s => s.id));
// Atomic transaction
await ctx.db.$transaction(async (tx) => {
// 1. Delete all R2 content for owned sites
for (const siteId of siteIds) {
await deleteProject(siteId);
}
// 2. Delete publications (cascades to sites, site_authors, subscriptions)
await tx.publication.deleteMany({ where: { ownerId: userId } });
// 3. Delete user (cascades to sessions, accounts, likes, remaining site_authors)
await tx.user.delete({ where: { id: userId } });
});
// 4. Background cleanup for user assets
await inngest.send({
name: "user/cleanup-assets",
data: { userId }
});
return { success: true };
})
New API Route
File: app/api/upload/avatar/route.ts
export async function POST(request: Request) {
const session = await getServerAuthSession();
if (!session?.user) return new Response("Unauthorized", { status: 401 });
const formData = await request.formData();
const file = formData.get("file") as File;
// Validation
if (!file || file.size > 5 * 1024 * 1024) {
return new Response("Invalid file", { status: 400 });
}
// Process image
const buffer = Buffer.from(await file.arrayBuffer());
const processedBuffer = await processAvatar(buffer);
// Upload to R2
await uploadUserAsset({
userId: session.user.id,
assetType: "avatar",
content: processedBuffer,
contentType: "image/webp",
});
// Generate URL and update database
const protocol = process.env.NODE_ENV === "development" ? "http://" : "https://";
const imageUrl = `${protocol}${env.NEXT_PUBLIC_S3_BUCKET_DOMAIN}/users/${session.user.id}/avatar.webp`;
await db.user.update({
where: { id: session.user.id },
data: { image: imageUrl }
});
return Response.json({ imageUrl });
}
Upload Flow:
- Client selects file → validates client-side
- Client POSTs to
/api/upload/avatarwith FormData - Server validates, processes (resize + convert), uploads to R2
- Server updates
user.imagewith public URL - Returns new image URL to client
Frontend Components
1. Main Page Component
File: app/dashboard/profile/page.tsx
- Server component
- Fetches session with
getServerAuthSession() - Renders three sections with clear visual separation
- Passes user data to child components
export default async function ProfilePage() {
const session = await getServerAuthSession();
if (!session?.user) redirect("/api/auth/signin");
return (
<div className="mx-auto max-w-4xl space-y-8 p-6">
<div>
<h1 className="text-3xl font-bold">Profile Settings</h1>
<p className="mt-2 text-muted-foreground">
Manage your account settings and preferences
</p>
</div>
<section className="rounded-lg border bg-card p-6">
<h2 className="text-xl font-semibold mb-4">Profile Information</h2>
<UsernameForm currentUsername={session.user.username || ""} />
</section>
<section className="rounded-lg border bg-card p-6">
<h2 className="text-xl font-semibold mb-4">Avatar</h2>
<AvatarForm currentImage={session.user.image || null} />
</section>
<section className="rounded-lg border border-destructive bg-card p-6">
<h2 className="text-xl font-semibold text-destructive mb-4">Danger Zone</h2>
<DeleteAccountForm username={session.user.username || ""} />
</section>
</div>
);
}
2. Username Form
File: app/dashboard/profile/_components/username-form.tsx
Features:
- Client component with controlled input
- Real-time validation (length, regex)
- Optimistic UI updates
- Error handling with toast notifications
- Disabled state during mutation
Validation Rules:
- Minimum 3 characters
- Maximum 30 characters
- Allowed: letters, numbers, hyphens, underscores
- Must be unique (server-side check)
3. Avatar Form
File: app/dashboard/profile/_components/avatar-form.tsx
Features:
- File input with hidden native element
- Live preview using FileReader
- Client-side validation (type, size)
- Upload progress state
- Image displayed in 512x512 circle
- Cancel button to revert changes
User Flow:
- Click "Choose File" → opens file picker
- Select image → instant preview appears
- Click "Save Avatar" → uploads with loading state
- Success → toast notification + refresh
Validation:
- File types: JPG, PNG, GIF, WebP
- Max size: 5MB
- Server processes to 512x512 WebP
4. Delete Account Form
File: app/dashboard/profile/_components/delete-account-form.tsx
Features:
- Modal dialog with two-step confirmation
- Fetches deletion statistics on modal open
- Shows what will be deleted:
- X publications owned
- Y posts in those publications
- Note about Z contributed posts (authorship removed)
- Type username to confirm
- Disabled delete button until username matches
- Signs out user after successful deletion
Confirmation Flow:
- Click "Delete Account" → opens modal
- Modal fetches stats (owned pubs, posts, contributions)
- User reads warnings
- User types exact username
- Click "Delete Account" (enabled only if match)
- Mutation executes → success → sign out → redirect to home
Cascade Deletion Logic
What Gets Deleted Automatically (Prisma Cascades)
User Deletion Cascades To:
SiteAuthor(removes authorship from ALL posts, owned or contributed)Session(invalidates all login sessions)Account(removes OAuth connections)Like(removes all likes)PublicationSubscription(removes subscriptions)
Publication Deletion Cascades To:
Site(all posts in the publication)PublicationSubscription(subscription records)
Site Deletion Cascades To:
Blob(database metadata records)SiteAuthor(authorship entries for that post)Like(likes on that post)SiteStat(analytics data)
What We Delete Manually
1. R2 Content (Site Files)
- All files under
{siteId}/{branch}/raw/*for each owned site - Handled via
deleteProject(siteId)function - Executed before database deletion in transaction
2. R2 Assets (User Avatar)
- All files under
users/{userId}/* - Handled via Inngest background job
- Executes after database transaction completes
- Non-blocking (allows immediate sign-out)
3. Owned Publications
- Explicitly deleted before user deletion
- Triggers cascade to sites and related entities
- Uses transaction to ensure atomicity
Deletion Order
1. Collect all site IDs from owned publications
2. Begin database transaction
a. Delete R2 content for each site (deleteProject)
b. Delete all publications owned by user (cascade to sites)
c. Delete user record (cascade to sessions, accounts, etc.)
3. End transaction
4. Schedule background job for user avatar cleanup
5. Sign out user and redirect to home
Safety Measures
- Transaction atomicity: All database operations in single transaction
- Username confirmation: Must type exact username to proceed
- Statistics preview: Shows exactly what will be deleted
- Post-deletion sign out: Immediately invalidates session
- Background cleanup: Non-blocking asset deletion prevents timeouts
Navigation Integration
User Dropdown Menu
File: components/user-dropdown.tsx
Add "Profile Settings" link after "My Subscriptions":
<MenuItem>
<Link
href="/dashboard/subscriptions"
className="block px-4 py-2 text-sm text-gray-700 data-[focus]:bg-gray-100"
>
My Subscriptions
</Link>
</MenuItem>
<MenuItem>
<Link
href="/dashboard/profile"
className="block px-4 py-2 text-sm text-gray-700 data-[focus]:bg-gray-100"
>
Profile Settings
</Link>
</MenuItem>
<MenuItem>
<button onClick={() => { posthog.reset(); signOut(); }}>
Sign out
</button>
</MenuItem>
Dependencies
New NPM Packages
npm install sharp
npm install @types/sharp --save-dev
sharp: High-performance image processing library
- Used for resizing images to 512x512
- Used for converting to WebP format
- Node.js native module (works in API routes)
Existing Dependencies (No Changes)
@aws-sdk/client-s3- Already used for R2@aws-sdk/s3-request-presigner- Already available- tRPC, Prisma, NextAuth - Core dependencies
- Inngest - Already used for background jobs
Security Considerations
Authentication
- All mutations require
protectedProcedure(authenticated session) - API route checks session via
getServerAuthSession() - No user can modify another user's data
Authorization
- Users can only update their own username/avatar
- Users can only delete their own account
- Cascade deletion only affects owned publications (not contributed ones)
Input Validation
Username:
- Client-side: regex + length validation
- Server-side: Zod schema validation + uniqueness check
Avatar Upload:
- Client-side: file type + size validation
- Server-side: file type + size re-validation
- Magic byte checking (optional future enhancement)
Account Deletion:
- Requires exact username match
- Double confirmation (modal + username input)
- Transaction ensures no partial deletions
Rate Limiting Considerations
Future Enhancement: Add rate limiting to:
/api/upload/avatar(prevent abuse)user.updateUsername(prevent spam/bruteforce)user.deleteAccount(prevent accidental rapid deletion)
Recommended: Use Upstash Rate Limit or similar
Utility Functions
Image Processing
File: lib/image-processor.ts (new)
import sharp from "sharp";
export async function processAvatar(buffer: Buffer): Promise<Buffer> {
return sharp(buffer)
.resize(512, 512, { fit: "cover", position: "center" })
.webp({ quality: 85 })
.toBuffer();
}
export function validateImageBuffer(buffer: Buffer): boolean {
const magicNumbers = {
jpg: [0xff, 0xd8, 0xff],
png: [0x89, 0x50, 0x4e, 0x47],
gif: [0x47, 0x49, 0x46],
webp: [0x52, 0x49, 0x46, 0x46],
};
return Object.values(magicNumbers).some((magic) =>
magic.every((byte, i) => buffer[i] === byte)
);
}
R2 Storage Helpers
File: lib/content-store.ts (extend existing)
export const uploadUserAsset = async ({
userId,
assetType,
content,
contentType,
}: {
userId: string;
assetType: "avatar";
content: Buffer;
contentType: string;
}) => {
const extension = contentType.split("/")[1] || "webp";
const key = `users/${userId}/${assetType}.${extension}`;
return uploadS3Object({ key, file: content, contentType });
};
export const deleteUserAssets = async (userId: string) => {
const s3Client = getS3Client();
const bucketName = getBucketName();
const listCommand = new ListObjectsV2Command({
Bucket: bucketName,
Prefix: `users/${userId}/`,
});
const objects = await s3Client.send(listCommand);
if (objects.Contents && objects.Contents.length > 0) {
const deleteCommand = new DeleteObjectsCommand({
Bucket: bucketName,
Delete: {
Objects: objects.Contents.map((obj) => ({ Key: obj.Key! })),
},
});
await s3Client.send(deleteCommand);
}
};
Inngest Background Job
File: inngest/functions.ts (add to exports)
import { deleteUserAssets } from "@/lib/content-store";
export const cleanupUserAssets = inngest.createFunction(
{ id: "cleanup-user-assets" },
{ event: "user/cleanup-assets" },
async ({ event }) => {
const { userId } = event.data;
try {
await deleteUserAssets(userId);
console.log(`✅ Cleaned up assets for user ${userId}`);
} catch (error) {
console.error(`❌ Failed to cleanup assets for user ${userId}:`, error);
throw error; // Let Inngest retry
}
}
);
Database Schema
No changes required! Existing schema already supports all features:
model User {
id String @id @default(cuid())
username String? // ✅ Used for username updates
image String? // ✅ Used for avatar URL storage
// ... other fields
// Relations with cascade delete configured
siteAuthors SiteAuthor[]
publications Publication[]
sessions Session[]
accounts Account[]
likes Like[]
publicationSubscriptions PublicationSubscription[]
}
model SiteAuthor {
// ✅ Cascade delete when user or site is deleted
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
}
Testing Checklist
Username Update
- Update username successfully
- Reject username with special characters
- Reject username < 3 characters
- Reject username > 30 characters
- Prevent duplicate usernames
- Show validation errors correctly
- Refresh session after update
Avatar Upload
- Upload JPG and see preview
- Upload PNG and see preview
- Upload GIF and see preview
- Upload WebP and see preview
- Reject file > 5MB
- Reject non-image file
- Verify image converted to WebP
- Verify image resized to 512x512
- Verify URL stored in database
- Cancel upload reverts preview
Account Deletion
- Modal shows correct statistics
- Delete button disabled until username entered
- Delete fails with wrong username
- Delete succeeds with correct username
- All owned publications deleted
- All owned sites deleted
- R2 site content deleted
- User avatar deleted (background)
- SiteAuthor entries removed
- Contributed posts remain (but authorship removed)
- User signed out after deletion
- Redirected to home page
Edge Cases
- Delete user with 0 publications
- Delete user with 10+ publications
- Delete user who contributed to many posts
- Upload avatar while previous upload in progress
- Change username while deletion in progress (should not be possible)
- Avatar upload timeout handling
- R2 cleanup retry on failure (Inngest)
Future Enhancements
Short Term
- Email on account deletion - Send confirmation email
- Download data - Export all user data before deletion (GDPR)
- Rate limiting - Prevent abuse of upload/update endpoints
- Audit log - Track username changes and deletions
Medium Term
- Profile banner image - Similar to avatar but full-width
- Bio/description field - Add user biography
- Social links - GitHub, Twitter, etc.
- Email preferences - Manage notification settings
Long Term
- Publication avatars - Similar upload flow for publications
- OG image generator - Auto-generate social preview images
- Avatar gallery - Choose from preset avatars
- Two-factor auth - Additional security before deletion
Rollout Plan
Phase 1: Development
- Add dependencies (
sharp) - Create utility functions (
image-processor.ts, extendcontent-store.ts) - Add tRPC endpoints (
updateUsername,getAccountStats,deleteAccount) - Create API route (
/api/upload/avatar) - Build frontend components (page + 3 forms)
- Add Inngest cleanup function
- Update navigation (user dropdown)
Phase 2: Testing
- Manual testing on development environment
- Test all scenarios in testing checklist
- Verify R2 cleanup works correctly
- Test cascade deletion thoroughly
- Performance test with large publications
Phase 3: Staging
- Deploy to staging environment
- Test with staging R2 bucket
- Verify all URLs resolve correctly
- Test end-to-end deletion flow
- Monitor Inngest background jobs
Phase 4: Production
- Deploy to production
- Monitor error logs
- Track usage analytics
- Gather user feedback
- Iterate on UX improvements
Success Metrics
- Adoption: % of users who visit profile settings within 30 days
- Username changes: Number of username updates per month
- Avatar uploads: % of users with custom avatars
- Deletions: Account deletion rate (should be low but measurable)
- Errors: Upload failure rate < 1%
- Performance: Avatar upload completes in < 10 seconds (p95)
Conclusion
This design provides a comprehensive user profile settings page with:
- Clean, intuitive UI with three distinct sections
- Robust avatar upload with automatic optimization
- Safe account deletion with cascade handling
- Proper R2 asset management following existing patterns
- Transaction-safe operations with background cleanup
- Security-first approach with validation and confirmation
The implementation follows existing project patterns (tRPC, Prisma, Inngest, R2) and requires minimal new dependencies (just sharp). All cascade relationships leverage existing Prisma configuration, ensuring data consistency.
Next step: Begin implementation following the rollout plan above.