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:

  1. Change their username
  2. Update their avatar (profile image)
  3. Delete their account with proper cascading deletion

Table of Contents


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:

  1. Profile Information (top)

    • Username update form
    • Validation: 3-30 chars, alphanumeric + hyphens/underscores
    • Real-time availability check
  2. Avatar (middle)

    • Image upload with live preview
    • File constraints: 5MB max, JPG/PNG/GIF/WebP
    • Auto-processing: resize to 512x512, convert to WebP
  3. 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:

  1. Client selects file → validates client-side
  2. Client POSTs to /api/upload/avatar with FormData
  3. Server validates, processes (resize + convert), uploads to R2
  4. Server updates user.image with public URL
  5. 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:

  1. Click "Choose File" → opens file picker
  2. Select image → instant preview appears
  3. Click "Save Avatar" → uploads with loading state
  4. 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:

  1. Click "Delete Account" → opens modal
  2. Modal fetches stats (owned pubs, posts, contributions)
  3. User reads warnings
  4. User types exact username
  5. Click "Delete Account" (enabled only if match)
  6. 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

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

  1. Email on account deletion - Send confirmation email
  2. Download data - Export all user data before deletion (GDPR)
  3. Rate limiting - Prevent abuse of upload/update endpoints
  4. Audit log - Track username changes and deletions

Medium Term

  1. Profile banner image - Similar to avatar but full-width
  2. Bio/description field - Add user biography
  3. Social links - GitHub, Twitter, etc.
  4. Email preferences - Manage notification settings

Long Term

  1. Publication avatars - Similar upload flow for publications
  2. OG image generator - Auto-generate social preview images
  3. Avatar gallery - Choose from preset avatars
  4. Two-factor auth - Additional security before deletion

Rollout Plan

Phase 1: Development

  1. Add dependencies (sharp)
  2. Create utility functions (image-processor.ts, extend content-store.ts)
  3. Add tRPC endpoints (updateUsername, getAccountStats, deleteAccount)
  4. Create API route (/api/upload/avatar)
  5. Build frontend components (page + 3 forms)
  6. Add Inngest cleanup function
  7. Update navigation (user dropdown)

Phase 2: Testing

  1. Manual testing on development environment
  2. Test all scenarios in testing checklist
  3. Verify R2 cleanup works correctly
  4. Test cascade deletion thoroughly
  5. Performance test with large publications

Phase 3: Staging

  1. Deploy to staging environment
  2. Test with staging R2 bucket
  3. Verify all URLs resolve correctly
  4. Test end-to-end deletion flow
  5. Monitor Inngest background jobs

Phase 4: Production

  1. Deploy to production
  2. Monitor error logs
  3. Track usage analytics
  4. Gather user feedback
  5. 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.