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
This commit is contained in:
Brian Fertig 2026-08-30 11:24:05 -06:00
parent 25f5c4240e
commit 83075c8791
13 changed files with 557 additions and 16 deletions

View File

@ -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

View File

@ -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 (
<SideNavProvider>
<ScheduledPanelProvider>
<ProjectsProvider initialProjects={projects}>
<ProjectsProvider initialProjects={projectList}>
<BoardViewProvider>
<HoldOnCompleteProvider>
{/* Responsive shell:
@ -51,7 +65,12 @@ export default async function AppLayout({ children }: { children: React.ReactNod
toolbar instead of the stale 100vh. */}
<div className="flex h-dvh flex-col md:h-screen md:min-w-[860px] md:flex-row md:overflow-x-auto">
<MobileTopBar />
<SideNav userEmail={session.user.email ?? ""} role={session.user.role} />
<SideNav
userEmail={session.user.email ?? ""}
userName={userName}
avatar={profile?.avatar ?? null}
role={session.user.role}
/>
<main className="min-h-0 flex-1 overflow-auto">{children}</main>
<ScheduledPanel />
</div>

View File

@ -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 (
<div className="flex h-full flex-col gap-4 p-4">
<div className="px-1">
<h1 className="font-heading text-xl font-bold tracking-tight">Profile</h1>
<p className="mt-0.5 text-[13px] text-muted-foreground">
Your name and photo, shown in the menu on the left.
</p>
</div>
<ProfileForm initial={profile} />
</div>
);
}

View File

@ -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
<img
src={avatar}
alt={userName ?? userEmail}
className={`${sizeClass} shrink-0 rounded-full object-cover`}
/>
);
}
const letter = (userName?.trim()[0] ?? userEmail[0] ?? "?").slice(0, 1);
return (
<span
className={`${sizeClass} flex shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary uppercase`}
>
{letter}
</span>
);
}
/**
* 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({
</div>
<div className={cn("flex items-center gap-2 border-t px-4 py-3", collapsed && "justify-center px-0")}>
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary uppercase">
{(userEmail[0] ?? "?").slice(0, 1)}
</span>
{!collapsed && (
<span className="truncate text-[13px] font-medium text-muted-foreground">{userEmail}</span>
{collapsed ? (
<Tooltip>
<TooltipTrigger
render={
<Link href="/profile" aria-label={`View profile (${userLabel})`} />
}
>
<UserAvatar
userName={userName}
userEmail={userEmail}
avatar={avatar}
sizeClass="size-7"
/>
</TooltipTrigger>
<TooltipContent side="right">{userLabel}</TooltipContent>
</Tooltip>
) : (
<Link
href="/profile"
className="flex min-w-0 flex-1 items-center gap-2"
aria-label="View profile"
>
<UserAvatar
userName={userName}
userEmail={userEmail}
avatar={avatar}
sizeClass="size-7"
/>
<span className="truncate text-[13px] font-medium text-muted-foreground">
{userLabel}
</span>
</Link>
)}
</div>
</>
@ -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 })
)}
>
<DialogPrimitive.Title className="sr-only">Menu</DialogPrimitive.Title>
<SideNavContent collapsed={false} userEmail={userEmail} role={role} themeMenu={{ side: "bottom", align: "end" }} />
<SideNavContent
collapsed={false}
userEmail={userEmail}
userName={userName}
avatar={avatar}
role={role}
themeMenu={{ side: "bottom", align: "end" }}
/>
</DialogPrimitive.Popup>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
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"
)}
>
<SideNavContent collapsed={collapsed} userEmail={userEmail} role={role} themeMenu={{ side: "right", align: "start" }} />
<SideNavContent
collapsed={collapsed}
userEmail={userEmail}
userName={userName}
avatar={avatar}
role={role}
themeMenu={{ side: "right", align: "start" }}
/>
<div className={cn("flex border-t px-2 py-1.5", collapsed ? "justify-center" : "justify-end")}>
<Button variant="ghost" size="icon" onClick={toggle} aria-label="Toggle sidebar">
@ -149,7 +254,7 @@ export function SideNav({ userEmail, role }: { userEmail: string; role: Role })
</div>
</aside>
<MobileNavDrawer userEmail={userEmail} role={role} />
<MobileNavDrawer userEmail={userEmail} userName={userName} avatar={avatar} role={role} />
</>
);
}

View File

@ -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<string | null>(initial.avatar);
const [savingNames, setSavingNames] = useState(false);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) {
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 (
<Card className="max-w-xl">
<CardContent className="space-y-5">
{/* Photo */}
<div className="flex items-center gap-4">
{avatar ? (
// eslint-disable-next-line @next/next/no-img-element -- data-URL avatar, no image-optimization pipeline in this self-hosted app
<img
src={avatar}
alt="Profile"
className="size-16 shrink-0 rounded-full object-cover"
/>
) : (
<span className="flex size-16 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xl font-bold text-primary">
{initials}
</span>
)}
<div className="flex flex-wrap items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_AVATAR_TYPES.join(",")}
className="hidden"
onChange={handleFileChosen}
disabled={uploading}
/>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Camera className="size-3.5" />
)}
{uploading ? "Updating…" : avatar ? "Change photo" : "Add photo"}
</Button>
{avatar && (
<Button
variant="ghost"
size="sm"
onClick={handleRemoveAvatar}
disabled={uploading}
>
<Trash2 className="size-3.5" />
Remove
</Button>
)}
</div>
</div>
{/* Name */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="profile-first-name">First name</Label>
<Input
id="profile-first-name"
value={firstName}
maxLength={60}
autoComplete="given-name"
placeholder="Alex"
onChange={(e) => setFirstName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && namesDirty && !savingNames && handleSaveNames()}
/>
</div>
<div className="space-y-2">
<Label htmlFor="profile-last-name">Last name</Label>
<Input
id="profile-last-name"
value={lastName}
maxLength={60}
autoComplete="family-name"
placeholder="Fertig"
onChange={(e) => setLastName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && namesDirty && !savingNames && handleSaveNames()}
/>
</div>
</div>
</CardContent>
<CardFooter>
<Button onClick={handleSaveNames} disabled={!namesDirty || savingNames}>
{savingNames ? (
<>
<Loader2 className="size-3.5 animate-spin" />
Saving
</>
) : (
"Save name"
)}
</Button>
</CardFooter>
</Card>
);
}

View File

@ -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

103
lib/actions/profile.ts Normal file
View File

@ -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<ProfileResult> {
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<UploadAvatarResult> {
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<ProfileResult> {
const userId = await requireUserId();
await prisma.user.update({ where: { id: userId }, data: { avatar: null } });
revalidateProfile();
return {};
}

View File

@ -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<typeof ProfileNameSchema>;

View File

@ -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;

View File

@ -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+';

View File

@ -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),

View File

@ -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,
},
});

7
types/profile.ts Normal file
View File

@ -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;
}