User Profile Settings Implementation Plan
User Profile Settings Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Build a user profile settings page allowing username changes, avatar uploads, and account deletion with proper cascade handling.
Architecture: Server-component page with three client-component forms, new tRPC endpoints for user mutations, Next.js API route for avatar upload with image processing, R2 storage with users/{userId}/ prefix, Inngest background cleanup.
Tech Stack: Next.js 14 App Router, tRPC, Prisma, NextAuth, Sharp (image processing), AWS S3 SDK (R2), Inngest
Task 1: Install Dependencies
Files:
- Modify:
package.json
Step 1: Install sharp for image processing
npm install sharp
npm install @types/sharp --save-dev
Step 2: Verify installation
Run: npm list sharp
Expected: Should show [email protected] installed
Step 3: Commit
git add package.json package-lock.json
git commit -m "deps: add sharp for image processing"
Task 2: Create Image Processing Utility
Files:
- Create:
lib/image-processor.ts
Step 1: Create the utility file
Create lib/image-processor.ts:
import sharp from "sharp";
/**
* Process and optimize avatar image
* - Resize to 512x512 (cover fit)
* - Convert to WebP format
* - Optimize quality
*/
export async function processAvatar(buffer: Buffer): Promise<Buffer> {
return sharp(buffer)
.resize(512, 512, {
fit: "cover",
position: "center",
})
.webp({ quality: 85 })
.toBuffer();
}
/**
* Validate image buffer by checking magic bytes
*/
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], // RIFF
};
return Object.values(magicNumbers).some((magic) =>
magic.every((byte, i) => buffer[i] === byte)
);
}
Step 2: Test that TypeScript compiles
Run: npx tsc --noEmit
Expected: No errors
Step 3: Commit
git add lib/image-processor.ts
git commit -m "feat: add image processing utility for avatars"
Task 3: Extend R2 Content Store with User Asset Functions
Files:
- Modify:
lib/content-store.ts
Step 1: Add uploadUserAsset function
Add to the end of lib/content-store.ts (before final export if needed):
/**
* Upload user asset (avatar, etc.) to R2
*/
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,
});
};
/**
* Delete all user assets from R2
*/
export const deleteUserAssets = async (userId: string) => {
const s3Client = getS3Client();
const bucketName = getBucketName();
// List all objects with prefix users/{userId}/
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);
}
};
Step 2: Test TypeScript compilation
Run: npx tsc --noEmit
Expected: No errors
Step 3: Commit
git add lib/content-store.ts
git commit -m "feat: add user asset upload/delete functions for R2"
Task 4: Add User Router Endpoints (Part 1 - Update Username)
Files:
- Modify:
server/api/routers/user.ts
Step 1: Add updateUsername mutation
Find the user router exports and add this mutation:
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_-]+$/,
"Username can only contain letters, numbers, hyphens, and underscores"
),
})
)
.mutation(async ({ ctx, input }) => {
// Check if username is already taken
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 username
return ctx.db.user.update({
where: { id: ctx.session.user.id },
data: { username: input.username },
});
}),
Step 2: Verify TypeScript compiles
Run: npx tsc --noEmit
Expected: No errors
Step 3: Test the dev server starts
Run: npm run dev
Expected: Server starts without errors (then stop it with Ctrl+C)
Step 4: Commit
git add server/api/routers/user.ts
git commit -m "feat: add updateUsername mutation to user router"
Task 5: Add User Router Endpoints (Part 2 - Account Stats)
Files:
- Modify:
server/api/routers/user.ts
Step 1: Add getAccountStats query
Add this query to the user router:
getAccountStats: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.id;
// Get count of owned publications
const publicationsCount = await ctx.db.publication.count({
where: { ownerId: userId },
});
// Get count of sites in owned publications
const ownedSitesCount = await ctx.db.site.count({
where: {
publication: {
ownerId: userId,
},
},
});
// Get count of sites user contributed to (but doesn't own)
const contributedSitesCount = await ctx.db.siteAuthor.count({
where: {
userId,
site: {
publication: {
ownerId: { not: userId },
},
},
},
});
return {
publicationsCount,
ownedSitesCount,
contributedSitesCount,
};
}),
Step 2: Verify TypeScript compiles
Run: npx tsc --noEmit
Expected: No errors
Step 3: Commit
git add server/api/routers/user.ts
git commit -m "feat: add getAccountStats query to user router"
Task 6: Add User Router Endpoints (Part 3 - Delete Account)
Files:
- Modify:
server/api/routers/user.ts
Step 1: Import deleteProject from content-store
Add to imports at top of server/api/routers/user.ts:
import { deleteProject } from "@/lib/content-store";
import { inngest } from "@/inngest/client";
Step 2: Add deleteAccount mutation
Add this mutation to the user router:
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 },
},
},
},
},
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
if (user.username !== input.confirmUsername) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Username confirmation does not match",
});
}
// Collect all site IDs that will be deleted
const siteIds = user.publications.flatMap((pub) =>
pub.sites.map((site) => site.id)
);
// Use transaction to ensure atomicity
await ctx.db.$transaction(async (tx) => {
// 1. Delete all R2 content for owned sites
for (const siteId of siteIds) {
await deleteProject(siteId);
}
// 2. Delete all owned publications (cascades to sites, site authors, subscriptions)
await tx.publication.deleteMany({
where: { ownerId: userId },
});
// 3. Delete user (cascades to: siteAuthors, sessions, accounts, likes)
await tx.user.delete({
where: { id: userId },
});
});
// 4. Schedule background cleanup for user assets (avatar, etc.)
await inngest.send({
name: "user/cleanup-assets",
data: { userId },
});
return { success: true };
}),
Step 3: Verify TypeScript compiles
Run: npx tsc --noEmit
Expected: No errors
Step 4: Commit
git add server/api/routers/user.ts
git commit -m "feat: add deleteAccount mutation with cascade logic"
Task 7: Create Avatar Upload API Route
Files:
- Create:
app/api/upload/avatar/route.ts
Step 1: Create the API route file
Create app/api/upload/avatar/route.ts:
import { getServerAuthSession } from "@/server/auth";
import { uploadUserAsset } from "@/lib/content-store";
import { processAvatar } from "@/lib/image-processor";
import { db } from "@/server/db";
import { env } from "@/env.mjs";
export const runtime = "nodejs";
export const maxDuration = 30; // 30 seconds max
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
export async function POST(request: Request) {
const session = await getServerAuthSession();
if (!session?.user) {
return new Response("Unauthorized", { status: 401 });
}
try {
const formData = await request.formData();
const file = formData.get("file") as File;
// Validate file exists
if (!file) {
return new Response("No file provided", { status: 400 });
}
// Validate file type
if (!ALLOWED_TYPES.includes(file.type)) {
return new Response("Invalid file type", { status: 400 });
}
// Validate file size
if (file.size > MAX_SIZE) {
return new Response("File too large", { status: 400 });
}
// Convert to buffer
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Process image (resize, convert to webp)
const processedBuffer = await processAvatar(buffer);
// Upload to R2
await uploadUserAsset({
userId: session.user.id,
assetType: "avatar",
content: processedBuffer,
contentType: "image/webp",
});
// Generate public URL
const protocol =
process.env.NODE_ENV === "development" ? "http://" : "https://";
const imageUrl = `${protocol}${env.NEXT_PUBLIC_S3_BUCKET_DOMAIN}/users/${session.user.id}/avatar.webp`;
// Update user record
await db.user.update({
where: { id: session.user.id },
data: { image: imageUrl },
});
return Response.json({ imageUrl });
} catch (error) {
console.error("Avatar upload error:", error);
return new Response("Upload failed", { status: 500 });
}
}
Step 2: Verify TypeScript compiles
Run: npx tsc --noEmit
Expected: No errors
Step 3: Commit
git add app/api/upload/avatar/route.ts
git commit -m "feat: add avatar upload API route with image processing"
Task 8: Create Inngest Cleanup Function
Files:
- Modify:
inngest/functions.ts
Step 1: Import deleteUserAssets
Add to imports at top of inngest/functions.ts:
import { deleteUserAssets } from "@/lib/content-store";
Step 2: Add cleanup function
Add this function to the file (and add to exports array at bottom):
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
}
}
);
Step 3: Add to exports array
Find the export statement and add cleanupUserAssets:
export const functions = [
// ... existing functions
cleanupUserAssets,
];
Step 4: Verify TypeScript compiles
Run: npx tsc --noEmit
Expected: No errors
Step 5: Commit
git add inngest/functions.ts
git commit -m "feat: add Inngest function for user asset cleanup"
Task 9: Create Username Form Component
Files:
- Create:
app/dashboard/profile/_components/username-form.tsx
Step 1: Create directory structure
mkdir -p app/dashboard/profile/_components
Step 2: Create username form component
Create app/dashboard/profile/_components/username-form.tsx:
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
interface UsernameFormProps {
currentUsername: string;
userId: string;
}
export default function UsernameForm({
currentUsername,
}: UsernameFormProps) {
const [username, setUsername] = useState(currentUsername);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const updateMutation = api.user.updateUsername.useMutation({
onSuccess: () => {
toast.success("Username updated successfully");
router.refresh();
},
onError: (error) => {
toast.error(error.message);
setError(error.message);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (username === currentUsername) {
setError("Username is unchanged");
return;
}
updateMutation.mutate({ username });
};
const isValid =
username.length >= 3 &&
username.length <= 30 &&
/^[a-zA-Z0-9_-]+$/.test(username);
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
value={username}
onChange={(e) => {
setUsername(e.target.value);
setError(null);
}}
placeholder="your-username"
disabled={updateMutation.isPending}
className={error ? "border-destructive" : ""}
/>
<p className="text-sm text-muted-foreground">
3-30 characters, letters, numbers, hyphens, and underscores only
</p>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<Button
type="submit"
disabled={
!isValid ||
username === currentUsername ||
updateMutation.isPending
}
>
{updateMutation.isPending ? "Saving..." : "Save Username"}
</Button>
</form>
);
}
Step 3: Verify TypeScript compiles
Run: npx tsc --noEmit
Expected: No errors
Step 4: Commit
git add app/dashboard/profile/_components/username-form.tsx
git commit -m "feat: add username form component"
Task 10: Create Avatar Form Component
Files:
- Create:
app/dashboard/profile/_components/avatar-form.tsx
Step 1: Create avatar form component
Create app/dashboard/profile/_components/avatar-form.tsx:
"use client";
import { useState, useRef } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { Upload, X } from "lucide-react";
interface AvatarFormProps {
currentImage: string | null;
userId: string;
}
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
export default function AvatarForm({ currentImage }: AvatarFormProps) {
const [preview, setPreview] = useState<string | null>(currentImage);
const [uploading, setUploading] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const router = useRouter();
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validate file type
if (!ALLOWED_TYPES.includes(file.type)) {
toast.error(
"Please select a valid image file (JPG, PNG, GIF, or WebP)"
);
return;
}
// Validate file size
if (file.size > MAX_SIZE) {
toast.error("File size must be less than 5MB");
return;
}
setSelectedFile(file);
// Create preview
const reader = new FileReader();
reader.onloadend = () => {
setPreview(reader.result as string);
};
reader.readAsDataURL(file);
};
const handleUpload = async () => {
if (!selectedFile) return;
setUploading(true);
try {
const formData = new FormData();
formData.append("file", selectedFile);
const response = await fetch("/api/upload/avatar", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error("Upload failed");
}
const data = await response.json();
toast.success("Avatar updated successfully");
setPreview(data.imageUrl);
setSelectedFile(null);
router.refresh();
} catch (error) {
toast.error("Failed to upload avatar");
console.error(error);
} finally {
setUploading(false);
}
};
const handleCancel = () => {
setPreview(currentImage);
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
return (
<div className="space-y-4">
<div className="flex items-start gap-6">
{/* Avatar Preview */}
<div className="relative h-32 w-32 overflow-hidden rounded-full border-2 border-border bg-muted">
{preview ? (
<Image
src={preview}
alt="Avatar preview"
fill
className="object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
No avatar
</div>
)}
</div>
{/* Upload Controls */}
<div className="flex-1 space-y-4">
<div>
<Label htmlFor="avatar-upload">Upload new avatar</Label>
<p className="text-sm text-muted-foreground mb-2">
JPG, PNG, GIF or WebP. Max 5MB. Will be converted to WebP and
resized to 512x512.
</p>
<input
ref={fileInputRef}
id="avatar-upload"
type="file"
accept={ALLOWED_TYPES.join(",")}
onChange={handleFileSelect}
disabled={uploading}
className="hidden"
/>
<Button
type="button"
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
<Upload className="mr-2 h-4 w-4" />
Choose File
</Button>
</div>
{selectedFile && (
<div className="flex gap-2">
<Button onClick={handleUpload} disabled={uploading}>
{uploading ? "Uploading..." : "Save Avatar"}
</Button>
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={uploading}
>
<X className="mr-2 h-4 w-4" />
Cancel
</Button>
</div>
)}
</div>
</div>
</div>
);
}
Step 2: Verify TypeScript compiles
Run: npx tsc --noEmit
Expected: No errors
Step 3: Commit
git add app/dashboard/profile/_components/avatar-form.tsx
git commit -m "feat: add avatar form component with upload"
Task 11: Create Delete Account Form Component
Files:
- Create:
app/dashboard/profile/_components/delete-account-form.tsx
Step 1: Create delete account form component
Create app/dashboard/profile/_components/delete-account-form.tsx:
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { signOut } from "next-auth/react";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { AlertTriangle } from "lucide-react";
import { toast } from "sonner";
interface DeleteAccountFormProps {
username: string;
userId: string;
}
export default function DeleteAccountForm({
username,
}: DeleteAccountFormProps) {
const [open, setOpen] = useState(false);
const [confirmUsername, setConfirmUsername] = useState("");
const router = useRouter();
// Fetch stats about what will be deleted
const { data: accountStats } = api.user.getAccountStats.useQuery(undefined, {
enabled: open,
});
const deleteMutation = api.user.deleteAccount.useMutation({
onSuccess: async () => {
toast.success("Account deleted successfully");
// Sign out and redirect to home
await signOut({ callbackUrl: "/" });
},
onError: (error) => {
toast.error(error.message);
},
});
const handleDelete = () => {
if (confirmUsername !== username) {
toast.error("Username does not match");
return;
}
deleteMutation.mutate({ confirmUsername });
};
const isValid = confirmUsername === username;
return (
<div className="space-y-4">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Deleting your account is permanent and cannot be undone. All
publications you own and their posts will be deleted. Your
contributions to other publications will remain but without author
attribution.
</AlertDescription>
</Alert>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="destructive">Delete Account</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Delete Account</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete your
account.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Show what will be deleted */}
{accountStats && (
<Alert>
<AlertDescription>
<p className="font-semibold mb-2">This will delete:</p>
<ul className="list-disc list-inside space-y-1 text-sm">
<li>{accountStats.publicationsCount} publication(s) you own</li>
<li>
{accountStats.ownedSitesCount} post(s) in those publications
</li>
<li>Your profile and avatar</li>
<li>All your sessions and connected accounts</li>
</ul>
{accountStats.contributedSitesCount > 0 && (
<p className="mt-2 text-sm text-muted-foreground">
Note: You are a contributor on{" "}
{accountStats.contributedSitesCount} other post(s). Your
authorship will be removed but the posts will remain.
</p>
)}
</AlertDescription>
</Alert>
)}
{/* Confirmation input */}
<div className="space-y-2">
<Label htmlFor="confirm-username">
Type <span className="font-mono font-semibold">{username}</span>{" "}
to confirm
</Label>
<Input
id="confirm-username"
type="text"
value={confirmUsername}
onChange={(e) => setConfirmUsername(e.target.value)}
placeholder="Enter your username"
disabled={deleteMutation.isPending}
autoComplete="off"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setOpen(false);
setConfirmUsername("");
}}
disabled={deleteMutation.isPending}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={!isValid || deleteMutation.isPending}
>
{deleteMutation.isPending ? "Deleting..." : "Delete Account"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
Step 2: Verify TypeScript compiles
Run: npx tsc --noEmit
Expected: No errors
Step 3: Commit
git add app/dashboard/profile/_components/delete-account-form.tsx
git commit -m "feat: add delete account form with confirmation"
Task 12: Create Profile Settings Page
Files:
- Create:
app/dashboard/profile/page.tsx
Step 1: Create profile page
Create app/dashboard/profile/page.tsx:
import { getServerAuthSession } from "@/server/auth";
import { redirect } from "next/navigation";
import UsernameForm from "./_components/username-form";
import AvatarForm from "./_components/avatar-form";
import DeleteAccountForm from "./_components/delete-account-form";
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">
{/* Page Header */}
<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>
{/* Profile Information Section */}
<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 || ""}
userId={session.user.id}
/>
</section>
{/* Avatar 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}
userId={session.user.id}
/>
</section>
{/* Danger Zone 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 || ""}
userId={session.user.id}
/>
</section>
</div>
);
}
Step 2: Verify TypeScript compiles
Run: npx tsc --noEmit
Expected: No errors
Step 3: Test dev server
Run: npm run dev
Expected: Server starts, navigate to http://localhost:3000/dashboard/profile (should require login)
Step 4: Commit
git add app/dashboard/profile/page.tsx
git commit -m "feat: add profile settings page with all sections"
Task 13: Update User Dropdown Navigation
Files:
- Modify:
components/user-dropdown.tsx
Step 1: Add Profile Settings link
Find the menu items in components/user-dropdown.tsx and add the 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 data-[focus]:outline-none"
>
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 data-[focus]:outline-none"
>
Profile Settings
</Link>
</MenuItem>
<MenuItem>
<button
onClick={() => {
posthog.reset();
signOut();
}}
className="block w-full px-4 py-2 text-left text-sm text-gray-700 data-[focus]:bg-gray-100 data-[focus]:outline-none"
>
Sign out
</button>
</MenuItem>
Step 2: Verify TypeScript compiles
Run: npx tsc --noEmit
Expected: No errors
Step 3: Test navigation
Run: npm run dev
- Navigate to dashboard
- Click user avatar dropdown
- Verify "Profile Settings" link appears
- Click it and verify you reach /dashboard/profile
Step 4: Commit
git add components/user-dropdown.tsx
git commit -m "feat: add profile settings link to user dropdown"
Task 14: Manual Testing - Username Update
Step 1: Start dev server
Run: npm run dev
Step 2: Test username update flow
- Navigate to http://localhost:3000/dashboard/profile
- Change username to a new valid value
- Click "Save Username"
- Verify: Success toast appears
- Verify: Page refreshes with new username
Step 3: Test username validation
- Try username with special characters (should show error)
- Try username < 3 characters (button disabled)
- Try username > 30 characters (button disabled)
- Try duplicate username if possible (should show "already taken")
Step 4: Check database
Run: npx prisma studio
- Open Users table
- Verify username was updated
Step 5: Document any issues
If any issues found, fix them before proceeding.
Task 15: Manual Testing - Avatar Upload
Step 1: Test avatar upload flow
- Navigate to http://localhost:3000/dashboard/profile
- Click "Choose File"
- Select a JPG image < 5MB
- Verify: Preview appears
- Click "Save Avatar"
- Verify: Success toast appears
- Verify: Avatar updates in dropdown and profile page
Step 2: Test file validation
- Try uploading file > 5MB (should show error toast)
- Try uploading non-image file (should show error toast)
- Try uploading PNG, GIF, WebP (all should work)
Step 3: Check R2 storage
- If using MinIO locally: http://localhost:9000
- Look for
users/{userId}/avatar.webp - Verify file exists and is WebP format
Step 4: Check database
Run: npx prisma studio
- Open Users table
- Verify
imagefield contains correct R2 URL
Step 5: Test cancel
- Select new file (preview appears)
- Click "Cancel"
- Verify: Preview reverts to current avatar
Task 16: Manual Testing - Account Deletion
Step 1: Create test user with data
- Sign in as test user
- Create 1-2 publications
- Add some posts to those publications
- Contribute to another user's publication (if possible)
Step 2: Test deletion flow
- Navigate to http://localhost:3000/dashboard/profile
- Scroll to "Danger Zone"
- Click "Delete Account"
- Verify: Modal opens
- Verify: Stats show correct counts
- Type incorrect username (verify button stays disabled)
- Type correct username (verify button enables)
- Click "Delete Account"
- Verify: Success toast appears
- Verify: Redirected to home page (signed out)
Step 3: Verify database cleanup
Run: npx prisma studio
- Verify user deleted
- Verify publications deleted
- Verify sites deleted
- Verify SiteAuthor entries deleted
Step 4: Verify R2 cleanup (may take a few seconds)
- Check MinIO or R2 console
- Verify
users/{userId}/folder deleted (background job) - Verify site content folders deleted
Step 5: Check Inngest logs
- Navigate to Inngest dashboard (local or cloud)
- Verify "user/cleanup-assets" event processed successfully
Task 17: Error Handling & Edge Cases
Step 1: Test network failures
- Open browser DevTools → Network tab
- Set throttling to "Offline"
- Try uploading avatar (should show error toast)
- Try updating username (should show error toast)
Step 2: Test concurrent operations
- Start avatar upload (don't wait for completion)
- Immediately try username update (both should complete)
Step 3: Test with missing avatar
- In database, set
imageto null for your user - Visit profile page
- Verify: Shows "No avatar" placeholder
- Upload avatar (should work normally)
Step 4: Test with long processing time
- Upload very large image (close to 5MB)
- Verify: Loading state shows
- Verify: Upload completes successfully
Step 5: Document any issues found
Fix critical issues before final commit.
Task 18: Final Verification & Build
Step 1: Run TypeScript check
Run: npx tsc --noEmit
Expected: No errors
Step 2: Run linter
Run: npm run lint
Expected: No errors (or fix any that appear)
Step 3: Test production build
Run: npm run build
Expected: Build succeeds without errors
Step 4: Test production mode
Run: npm run start
- Navigate through profile settings
- Test all three features (username, avatar, delete)
- Verify everything works in production mode
Step 5: Stop server
Press Ctrl+C to stop the production server
Task 19: Final Commit & Documentation
Step 1: Review all changes
Run: git status
Expected: All files added/modified properly
Step 2: Create final commit
git add -A
git commit -m "feat: complete user profile settings page
- Add username update with validation
- Add avatar upload with image processing
- Add account deletion with cascade logic
- Add navigation integration
- Include R2 storage setup for user assets
- Add Inngest background cleanup job"
Step 3: Verify commit
Run: git log -1 --stat
Expected: Shows comprehensive commit with all files
Step 4: Update design document status
Edit docs/plans/2026-02-11-user-profile-settings-design.md:
Change first line to:
**Status**: ✅ Implemented
Step 5: Final commit for docs
git add docs/plans/2026-02-11-user-profile-settings-design.md
git commit -m "docs: mark profile settings design as implemented"
Completion Checklist
- Dependencies installed (sharp)
- Image processing utility created
- R2 content store extended
- User router endpoints added (username, stats, delete)
- Avatar upload API route created
- Inngest cleanup function added
- Username form component created
- Avatar form component created
- Delete account form component created
- Profile settings page created
- Navigation updated
- Username update tested
- Avatar upload tested
- Account deletion tested
- Error handling verified
- Production build successful
- Documentation updated
Known Limitations & Future Work
- Rate Limiting: No rate limiting on upload/update endpoints (consider adding)
- Email Notifications: No email sent on account deletion (could add)
- Data Export: No GDPR data export before deletion (future enhancement)
- Avatar Gallery: Could add preset avatar selection
- Audit Log: Could track all username changes
See design document for full list of future enhancements.