From 83075c8791a19253376ccf4dfc09603c5e93e53b Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sun, 30 Aug 2026 11:24:05 -0600 Subject: [PATCH] Add user profile page with name and photo support - New `/profile` page lets users set first/last name and upload a profile photo (resized to 256x256 JPEG server-side via sharp, stored as a data URL in Postgres since the container FS is ephemeral) - Sidebar user block now shows the profile photo (or initial-letter fallback) and display name, and links to `/profile` - Migration adds `firstName`, `lastName`, `avatar` columns to User and backfills them from the legacy `name` field; seed updated to match - Bump Next.js server-action body limit to 11 MB so avatar uploads up to the 10 MB cap aren't silently rejected with a 413 - Expose Postgres port on the host in docker-compose for local tooling --- README.md | 3 + app/(app)/layout.tsx | 31 ++- app/(app)/profile/page.tsx | 38 ++++ components/nav/side-nav.tsx | 125 ++++++++++- components/profile/profile-form.tsx | 211 ++++++++++++++++++ docker-compose.yml | 5 + lib/actions/profile.ts | 103 +++++++++ lib/validation/profile.ts | 9 + next.config.ts | 9 + .../migration.sql | 16 ++ prisma/schema.prisma | 11 + prisma/seed.ts | 5 + types/profile.ts | 7 + 13 files changed, 557 insertions(+), 16 deletions(-) create mode 100644 app/(app)/profile/page.tsx create mode 100644 components/profile/profile-form.tsx create mode 100644 lib/actions/profile.ts create mode 100644 lib/validation/profile.ts create mode 100644 prisma/migrations/20260830170317_add_user_profile/migration.sql create mode 100644 types/profile.ts diff --git a/README.md b/README.md index 09228a3..843a887 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,9 @@ given instance automatically becomes its administrator. password, or delete their account - control how the site handles new sign-ups (see [Sign-up modes](#sign-up-modes) below) +- **Profile page** (`/profile`): set a first/last name and a profile photo, + shown in the menu on the left (the photo replaces the initial-letter + avatar when set) ## Tech stack diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index b17523d..5e0c3b4 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -23,22 +23,36 @@ export default async function AppLayout({ children }: { children: React.ReactNod // board (categories/groups/todos) is fetched separately by its own page. // `theme` is stored as free text; the set of valid names is enforced // client-side (ProjectThemeSchema) so a plain assertion is safe here. - const projects: ProjectDTO[] = ( - await prisma.project.findMany({ + const [projects, profile] = await Promise.all([ + prisma.project.findMany({ where: { ownerId: session.user.id }, orderBy: { createdAt: "asc" }, select: { id: true, title: true, theme: true }, - }) - ).map((p) => ({ + }), + // The user's profile for the sidebar's bottom user block (name + photo, + // both optional); the /profile page fetches the same row itself. + prisma.user.findUnique({ + where: { id: session.user.id }, + select: { firstName: true, lastName: true, avatar: true }, + }), + ]); + + const projectList: ProjectDTO[] = projects.map((p) => ({ id: p.id, title: p.title, theme: p.theme as ThemeName | null, })); + const userName = + [profile?.firstName, profile?.lastName] + .filter((part): part is string => Boolean(part && part.trim())) + .map((part) => part.trim()) + .join(" ") || null; + return ( - + {/* Responsive shell: @@ -51,7 +65,12 @@ export default async function AppLayout({ children }: { children: React.ReactNod toolbar instead of the stale 100vh. */}
- +
{children}
diff --git a/app/(app)/profile/page.tsx b/app/(app)/profile/page.tsx new file mode 100644 index 0000000..60454b7 --- /dev/null +++ b/app/(app)/profile/page.tsx @@ -0,0 +1,38 @@ +import { redirect } from "next/navigation"; + +import { auth } from "@/auth"; +import { prisma } from "@/lib/db"; +import type { ProfileDTO } from "@/types/profile"; +import { ProfileForm } from "@/components/profile/profile-form"; + +export default async function ProfilePage() { + const session = await auth(); + // Defense in depth: proxy.ts already redirects unauthenticated requests, + // but every protected data boundary should check for itself too. + if (!session?.user) redirect("/login"); + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { firstName: true, lastName: true, avatar: true }, + }); + if (!user) redirect("/login"); + + const profile: ProfileDTO = { + firstName: user.firstName, + lastName: user.lastName, + avatar: user.avatar, + }; + + return ( +
+
+

Profile

+

+ Your name and photo, shown in the menu on the left. +

+
+ + +
+ ); +} diff --git a/components/nav/side-nav.tsx b/components/nav/side-nav.tsx index 65f71ed..cb3156f 100644 --- a/components/nav/side-nav.tsx +++ b/components/nav/side-nav.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { ChevronLeft, ChevronRight, LogOut, NotebookPen } from "lucide-react"; import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; @@ -14,6 +15,42 @@ import { ThemeToggle } from "@/components/theme/theme-toggle"; import { logout } from "@/lib/actions/auth"; import { Role } from "@/lib/generated/prisma/enums"; +/** + * The signed-in user's circle at the bottom of the nav: their photo when + * they've set one, otherwise the letter fallback -- and a link to the + * Profile page either way. + */ +function UserAvatar({ + userName, + userEmail, + avatar, + sizeClass, +}: { + userName: string | null; + userEmail: string; + avatar: string | null; + sizeClass: string; +}) { + if (avatar) { + return ( + // eslint-disable-next-line @next/next/no-img-element -- data-URL avatars, no image-optimization pipeline in this self-hosted app + {userName + ); + } + const letter = (userName?.trim()[0] ?? userEmail[0] ?? "?").slice(0, 1); + return ( + + {letter} + + ); +} + /** * The actual nav content, shared by the desktop aside and the mobile * hamburger drawer so the two never drift apart. `collapsed` is always @@ -22,11 +59,15 @@ import { Role } from "@/lib/generated/prisma/enums"; function SideNavContent({ collapsed, userEmail, + userName, + avatar, role, themeMenu, }: { collapsed: boolean; userEmail: string; + userName: string | null; + avatar: string | null; role: Role; // Where the theme picker menu opens from its trigger -- right of the // trigger in the desktop sidebar, below it in the mobile drawer (a @@ -34,6 +75,9 @@ function SideNavContent({ themeMenu: { side: "right" | "bottom"; align: "start" | "end" }; }) { const adminItem = role === Role.ADMIN ? [ADMIN_NAV_ITEM] : []; + // The user's display label: their name when set, otherwise the email + // (same as before profiles existed). + const userLabel = userName || userEmail; return ( <> @@ -85,11 +129,38 @@ function SideNavContent({
- - {(userEmail[0] ?? "?").slice(0, 1)} - - {!collapsed && ( - {userEmail} + {collapsed ? ( + + + } + > + + + {userLabel} + + ) : ( + + + + {userLabel} + + )}
@@ -100,7 +171,17 @@ function SideNavContent({ * Mobile (< md) hamburger drawer: the full side nav in a left sheet. * Tapping any link closes it; the backdrop and Escape close it too. */ -function MobileNavDrawer({ userEmail, role }: { userEmail: string; role: Role }) { +function MobileNavDrawer({ + userEmail, + userName, + avatar, + role, +}: { + userEmail: string; + userName: string | null; + avatar: string | null; + role: Role; +}) { const { mobileOpen, setMobileOpen } = useSideNav(); return ( @@ -122,14 +203,31 @@ function MobileNavDrawer({ userEmail, role }: { userEmail: string; role: Role }) )} > Menu - + ); } -export function SideNav({ userEmail, role }: { userEmail: string; role: Role }) { +export function SideNav({ + userEmail, + userName, + avatar, + role, +}: { + userEmail: string; + userName: string | null; + avatar: string | null; + role: Role; +}) { const { collapsed, toggle } = useSideNav(); return ( @@ -140,7 +238,14 @@ export function SideNav({ userEmail, role }: { userEmail: string; role: Role }) collapsed ? "w-16" : "w-60" )} > - +
- + ); } diff --git a/components/profile/profile-form.tsx b/components/profile/profile-form.tsx new file mode 100644 index 0000000..162988c --- /dev/null +++ b/components/profile/profile-form.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { useRef, useState } from "react"; +import { toast } from "sonner"; +import { Camera, Loader2, Trash2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardFooter } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + removeAvatar as removeAvatarAction, + updateProfileNames, + uploadAvatar as uploadAvatarAction, +} from "@/lib/actions/profile"; +import type { ProfileDTO } from "@/types/profile"; + +// Kept in sync with lib/actions/profile.ts (the server re-checks both). +const MAX_AVATAR_BYTES = 10 * 1024 * 1024; +const ACCEPTED_AVATAR_TYPES = [ + "image/jpeg", + "image/png", + "image/webp", + "image/gif", +]; + +export function ProfileForm({ initial }: { initial: ProfileDTO }) { + const [firstName, setFirstName] = useState(initial.firstName ?? ""); + const [lastName, setLastName] = useState(initial.lastName ?? ""); + // What's currently saved in the DB -- the "Save" button is only enabled + // while the inputs differ from this. + const [savedNames, setSavedNames] = useState({ + firstName: initial.firstName ?? "", + lastName: initial.lastName ?? "", + }); + const [avatar, setAvatar] = useState(initial.avatar); + const [savingNames, setSavingNames] = useState(false); + const [uploading, setUploading] = useState(false); + const fileInputRef = useRef(null); + + const namesDirty = + firstName.trim() !== savedNames.firstName || + lastName.trim() !== savedNames.lastName; + + async function handleSaveNames() { + setSavingNames(true); + try { + await updateProfileNames(firstName, lastName); + setSavedNames({ firstName, lastName }); + toast.success("Name saved."); + } catch { + toast.error("Couldn't save your name. Try again."); + } finally { + setSavingNames(false); + } + } + + async function handleFileChosen(event: React.ChangeEvent) { + const file = event.target.files?.[0]; + event.target.value = ""; // allow re-picking the same file + if (!file) return; + + // Same rules as the server action -- check early so a bad file never + // leaves the browser. + if (!ACCEPTED_AVATAR_TYPES.includes(file.type)) { + toast.error("Please choose a JPEG, PNG, WebP, or GIF image."); + return; + } + if (file.size > MAX_AVATAR_BYTES) { + toast.error("Image must be 10 MB or smaller."); + return; + } + + setUploading(true); + try { + const result = await uploadAvatarAction(file); + if (result.error) { + toast.error(result.error); + return; + } + setAvatar(result.avatar ?? null); + toast.success("Profile photo updated."); + } catch (error) { + // A thrown (rather than returned) error is a framework-level + // rejection -- typically Next.js' server-action body limit 413ing + // the file before it reaches the action. Say so instead of the + // generic "try another image", which is misleading here. + if (error instanceof Error && /body exceeded|413/i.test(error.message)) { + toast.error( + "Image is too large for the server to accept. Try one under 10 MB." + ); + } else { + toast.error("Couldn't upload that photo. Try again."); + } + } finally { + setUploading(false); + } + } + + async function handleRemoveAvatar() { + setUploading(true); + try { + await removeAvatarAction(); + setAvatar(null); + toast.success("Profile photo removed."); + } catch { + toast.error("Couldn't remove the photo. Try again."); + } finally { + setUploading(false); + } + } + + const initials = (firstName.trim()[0] ?? lastName.trim()[0] ?? "?").toUpperCase(); + + return ( + + + {/* Photo */} +
+ {avatar ? ( + // eslint-disable-next-line @next/next/no-img-element -- data-URL avatar, no image-optimization pipeline in this self-hosted app + Profile + ) : ( + + {initials} + + )} +
+ + + {avatar && ( + + )} +
+
+ + {/* Name */} +
+
+ + setFirstName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && namesDirty && !savingNames && handleSaveNames()} + /> +
+
+ + setLastName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && namesDirty && !savingNames && handleSaveNames()} + /> +
+
+
+ + + + +
+ ); +} diff --git a/docker-compose.yml b/docker-compose.yml index dcbe8cc..1c78194 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,11 @@ services: volumes: # Local filesystem bind mount, not a named Docker volume. - ./data/postgres:/var/lib/postgresql/data + ports: + # Exposed on the host so host-side tooling (prisma migrate/dev, npm run + # dev with the .env's localhost DATABASE_URL) can reach it. The app + # container still connects over the compose network's "db" hostname. + - "${DB_PORT:-5432}:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 5s diff --git a/lib/actions/profile.ts b/lib/actions/profile.ts new file mode 100644 index 0000000..ef861f1 --- /dev/null +++ b/lib/actions/profile.ts @@ -0,0 +1,103 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import sharp from "sharp"; + +import { prisma } from "@/lib/db"; +import { requireUserId } from "@/lib/auth-helpers"; +import { ProfileNameSchema } from "@/lib/validation/profile"; + +/** + * Refresh both the Profile page (its form's initial values) and the (app) + * layout (the sidebar shows the user's name/photo) after a change. + * "layout" type invalidates the whole (app) tree -- see + * next/docs revalidatePath "Revalidating a Layout path". + */ +function revalidateProfile() { + revalidatePath("/profile"); + revalidatePath("/", "layout"); +} + +export type ProfileResult = { error?: string }; + +/** + * Sets the user's first and last name (either may be blank). Blank values + * are stored as null so "cleared" and "never set" mean the same thing. + */ +export async function updateProfileNames( + firstName: string, + lastName: string +): Promise { + const userId = await requireUserId(); + const parsed = ProfileNameSchema.parse({ firstName, lastName }); + + await prisma.user.update({ + where: { id: userId }, + data: { + firstName: parsed.firstName || null, + lastName: parsed.lastName || null, + }, + }); + + revalidateProfile(); + return {}; +} + +const MAX_AVATAR_BYTES = 10 * 1024 * 1024; +const ALLOWED_AVATAR_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/webp", + "image/gif", +]); +// Avatars render at most a couple of dozen CSS pixels; 256x256 covers +// retina without bloat. Re-encoded as JPEG so every row stored in the DB +// has the same shape regardless of the upload's original format. +const AVATAR_SIZE = 256; + +export type UploadAvatarResult = { avatar?: string; error?: string }; + +/** + * Stores the user's profile photo. The upload is resized to 256x256 + * (cover crop, EXIF-rotated) and re-encoded as JPEG server-side, then + * saved as a data URL on the User row -- the app's container filesystem + * is ephemeral (only the Postgres volume survives deploys), so the image + * lives in the database rather than on disk. + */ +export async function uploadAvatar(file: File): Promise { + const userId = await requireUserId(); + + if (!ALLOWED_AVATAR_TYPES.has(file.type)) { + return { error: "Please choose a JPEG, PNG, WebP, or GIF image." }; + } + if (file.size === 0) return { error: "That file is empty." }; + if (file.size > MAX_AVATAR_BYTES) { + return { error: "Image must be 10 MB or smaller." }; + } + + let encoded: Buffer; + try { + encoded = await sharp(Buffer.from(await file.arrayBuffer())) + .rotate() // honor EXIF orientation from phones' photos + .resize(AVATAR_SIZE, AVATAR_SIZE, { fit: "cover" }) + .jpeg({ quality: 85 }) + .toBuffer(); + } catch { + return { error: "That doesn't look like a valid image." }; + } + + const avatar = `data:image/jpeg;base64,${encoded.toString("base64")}`; + await prisma.user.update({ where: { id: userId }, data: { avatar } }); + + revalidateProfile(); + return { avatar }; +} + +export async function removeAvatar(): Promise { + const userId = await requireUserId(); + + await prisma.user.update({ where: { id: userId }, data: { avatar: null } }); + + revalidateProfile(); + return {}; +} diff --git a/lib/validation/profile.ts b/lib/validation/profile.ts new file mode 100644 index 0000000..6ccf89c --- /dev/null +++ b/lib/validation/profile.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +const NamePartSchema = z.string().trim().max(60, "Must be 60 characters or fewer"); + +export const ProfileNameSchema = z.object({ + firstName: NamePartSchema, + lastName: NamePartSchema, +}); +export type ProfileName = z.infer; diff --git a/next.config.ts b/next.config.ts index ca768a6..6a98a28 100644 --- a/next.config.ts +++ b/next.config.ts @@ -7,6 +7,15 @@ const nextConfig: NextConfig = { // container start, not imported by app code). The Dockerfile instead // copies the full `node_modules` into the runtime image, which is a // simpler and more reliable trade for a single self-hosted instance. + experimental: { + serverActions: { + // Default is 1 MB, which silently 413s profile-photo uploads larger + // than that before the action even runs (the client only sees a + // generic failure). Allow the profile action's 10 MB max file plus + // room for multipart overhead; other actions send tiny bodies. + bodySizeLimit: "11mb", + }, + }, }; export default nextConfig; diff --git a/prisma/migrations/20260830170317_add_user_profile/migration.sql b/prisma/migrations/20260830170317_add_user_profile/migration.sql new file mode 100644 index 0000000..68035bf --- /dev/null +++ b/prisma/migrations/20260830170317_add_user_profile/migration.sql @@ -0,0 +1,16 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "avatar" TEXT, +ADD COLUMN "firstName" TEXT, +ADD COLUMN "lastName" TEXT; + +-- Backfill: existing accounts only have the single `name` captured at +-- sign-up. Split it into firstName (first word) and lastName (the rest) so +-- the Profile page starts from what the user already gave us instead of a +-- blank form. Single-word names (e.g. "Cher") get firstName only. +UPDATE "User" +SET "firstName" = split_part("name", ' ', 1) +WHERE "name" IS NOT NULL AND btrim("name") <> ''; + +UPDATE "User" +SET "lastName" = trim(regexp_replace("name", '^\S+\s+', '')) +WHERE "name" IS NOT NULL AND "name" ~ '^\S+\s+\S+'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b20f587..7e6a36d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -34,7 +34,18 @@ model User { id String @id @default(cuid()) email String @unique passwordHash String + // Legacy single name field, captured at sign-up. Display and the Admin + // page still use it; the Profile page manages firstName/lastName below. name String? + // Profile page fields. Null = the user hasn't set them. + firstName String? + lastName String? + // Profile photo, stored as a data URL (base64 JPEG) rather than a file on + // disk: this app's container filesystem is ephemeral (only the Postgres + // volume persists across deploys), so the DB is the one place it can live. + // Always server-generated from the user's upload (resized to 256x256 via + // sharp in lib/actions/profile.ts), never stored as the raw upload. + avatar String? // The very first person to sign up becomes ADMIN regardless of // signupMode (the site needs at least one admin to bootstrap). Everyone // after that gets USER (signupMode OPEN) or PENDING (APPROVED/CONFIRMED), diff --git a/prisma/seed.ts b/prisma/seed.ts index a63a393..b9740e3 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -17,6 +17,11 @@ async function main() { email: "dev@example.com", passwordHash, name: "Dev User", + // Mirrors the migration's backfill of the legacy `name` into the + // profile fields (first word / rest) so seeded accounts look the + // same as ones that predate the Profile page. + firstName: "Dev", + lastName: "User", role: Role.ADMIN, }, }); diff --git a/types/profile.ts b/types/profile.ts new file mode 100644 index 0000000..905ce2f --- /dev/null +++ b/types/profile.ts @@ -0,0 +1,7 @@ +/** The signed-in user's profile as rendered by the Profile page and the + * sidebar -- nulls mean "not set". */ +export interface ProfileDTO { + firstName: string | null; + lastName: string | null; + avatar: string | null; +}