diff --git a/README.md b/README.md index 09228a3..7729bcb 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,10 @@ given instance automatically becomes its administrator. - Per-Group markdown notes and a to-do list with a completion progress indicator - Light/dark theme (follows system by default) +- **Per-account theme**: the theme you pick (theme menu, bottom-left) is + saved on that account's profile, so each account keeps its own look — + after an account toggle you land in the other account's theme. Applies + from the first frame of each page load (no flash) - Installable PWA (add-to-home-screen); requires a live connection to the server for its database, so there's no offline mode - Credentials-based accounts (email + password), with an Admin page to: @@ -26,6 +30,24 @@ 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). The account's default theme lives on the profile too — + pick it from the theme menu (bottom-left of the sidebar); it persists + per account, not per browser +- **Account linking** (Profile page): link multiple accounts on the same + server so one person can juggle more than one identity. + - *Request Account Link* — ask to link by the other account's email; + the request shows "awaiting confirmation" until they respond + - *Requested Account Link* — the recipient's list of pending requests, + with **Create Account Link**, **Deny Account Link**, or + **Deny and Block Account Link** (blocks that account from requesting + again; blocks can be lifted from the *Blocked Account Link Requests* + section) + - *Linked Accounts* — the other side of each confirmed link, with + **Toggle** (signs this browser out and back in as that account — it + never asks for that account's password) and **Remove Link** (either + linked account can do it) ## 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..33f2c2d --- /dev/null +++ b/app/(app)/profile/page.tsx @@ -0,0 +1,134 @@ +import { redirect } from "next/navigation"; + +import { auth } from "@/auth"; +import { prisma } from "@/lib/db"; +import type { + BlockedRequesterDTO, + LinkedAccountDTO, + PendingLinkRequestDTO, + ProfileDTO, +} from "@/types/profile"; +import { ProfileForm } from "@/components/profile/profile-form"; +import { RequestLinkForm } from "@/components/profile/request-link-form"; +import { PendingLinkRequests } from "@/components/profile/pending-link-requests"; +import { LinkedAccounts } from "@/components/profile/linked-accounts"; +import { BlockedLinkRequests } from "@/components/profile/blocked-link-requests"; + +const LINK_ACCOUNT_SELECT = { + id: true, + email: true, + name: true, + firstName: true, + lastName: true, + avatar: true, +} as const; + +type LinkAccountRow = { + id: string; + email: string; + name: string | null; + firstName: string | null; + lastName: string | null; + avatar: string | null; +}; + +/** Display name for another account: profile first/last name when set, + * falling back to the legacy sign-up name. */ +function identity(row: LinkAccountRow) { + const full = [row.firstName, row.lastName].filter(Boolean).join(" ").trim(); + return { + id: row.id, + email: row.email, + name: full || row.name?.trim() || null, + avatar: row.avatar, + }; +} + +function dateLabel(date: Date) { + return date.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +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 userId = session.user.id; + + const [user, pendingRequests, links, blocks] = await Promise.all([ + prisma.user.findUnique({ + where: { id: userId }, + select: { name: true, firstName: true, lastName: true, avatar: true }, + }), + // Requests addressed to this account -- the ones it can act on. + prisma.accountLinkRequest.findMany({ + where: { toUserId: userId }, + orderBy: { createdAt: "desc" }, + include: { fromUser: { select: LINK_ACCOUNT_SELECT } }, + }), + // Confirmed links in either direction; the other side of the pair is + // whichever isn't this account. + prisma.accountLink.findMany({ + where: { OR: [{ userId }, { linkedUserId: userId }] }, + include: { + user: { select: LINK_ACCOUNT_SELECT }, + linkedUser: { select: LINK_ACCOUNT_SELECT }, + }, + }), + // Blocks this account issued: who is barred from requesting it. + prisma.accountLinkBlock.findMany({ + where: { toUserId: userId }, + orderBy: { createdAt: "desc" }, + include: { fromUser: { select: LINK_ACCOUNT_SELECT } }, + }), + ]); + if (!user) redirect("/login"); + + const profile: ProfileDTO = { + firstName: user.firstName, + lastName: user.lastName, + avatar: user.avatar, + }; + + const pending: PendingLinkRequestDTO[] = pendingRequests.map((request) => ({ + requestId: request.id, + requestedAtLabel: dateLabel(request.createdAt), + ...identity(request.fromUser), + })); + + const linked: LinkedAccountDTO[] = links.map((link) => ({ + linkId: link.id, + ...identity(link.userId === userId ? link.linkedUser : link.user), + })); + + const blocked: BlockedRequesterDTO[] = blocks.map((block) => ({ + blockId: block.id, + blockedAtLabel: dateLabel(block.createdAt), + ...identity(block.fromUser), + })); + + return ( +
+
+

Profile

+

+ Your name and photo, shown in the menu on the left — and your + links to other accounts on this server. +

+
+ + + +
+ + {pending.length > 0 && } + {linked.length > 0 && } + {blocked.length > 0 && } +
+
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx index 12cbf72..61d2e63 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -15,6 +15,9 @@ import { ThemeProvider } from "@/components/theme/theme-provider"; import { TooltipProvider } from "@/components/ui/tooltip"; import { Toaster } from "@/components/ui/sonner"; import { RegisterServiceWorker } from "@/components/pwa/register-sw"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/db"; +import { themeInitScript, type RawTheme } from "@/lib/themes"; /** UI type: Inter -- a neutral, highly legible humanist sans that reads * cleanly at small sizes (to-do lists, tables, nav). */ @@ -94,7 +97,28 @@ export const viewport: Viewport = { ], }; -export default function RootLayout({ children }: LayoutProps<"/">) { +export default async function RootLayout({ children }: LayoutProps<"/">) { + // Per-user global theme (stored on the profile row; set from the theme + // menu, see saveUserTheme in lib/actions/profile.ts). When signed in it + // is authoritative: the no-FOUC script below applies it before first + // paint and the ThemeProvider starts from it and persists changes back + // -- so each account in a linked set keeps its own look in the same + // browser across account toggles (a toggle is a full navigation, and + // this layout re-renders for the new user). Anonymous visitors keep the + // old localStorage-based behavior. + const session = await auth(); + let userTheme: RawTheme | undefined; + if (session?.user) { + // Stored as free text; valid values are enforced by ThemeSchema + // (lib/validation/profile.ts) at write time, so the assertion is safe + // -- same pattern as Project.theme. + const profile = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { theme: true }, + }); + userTheme = (profile?.theme as RawTheme | null) ?? "system"; + } + return ( ) { their own fonts, backgrounds, and animations. "system" resolves to Default/Dark by OS preference. No-FOUC theme restore: runs before first paint, applies the - stored preference to (class + color-scheme). */} + right class + color-scheme to . Signed-in users get + their profile's stored theme (per account, so it survives + account toggles); anonymous visitors get the browser's + localStorage preference -- see themeInitScript in lib/themes.ts. */}