diff --git a/README.md b/README.md index 843a887..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: @@ -28,7 +32,22 @@ given instance automatically becomes its administrator. 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) + 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)/profile/page.tsx b/app/(app)/profile/page.tsx index 60454b7..33f2c2d 100644 --- a/app/(app)/profile/page.tsx +++ b/app/(app)/profile/page.tsx @@ -2,19 +2,90 @@ import { redirect } from "next/navigation"; import { auth } from "@/auth"; import { prisma } from "@/lib/db"; -import type { ProfileDTO } from "@/types/profile"; +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 = await prisma.user.findUnique({ - where: { id: session.user.id }, - select: { firstName: true, lastName: true, avatar: true }, - }); + 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 = { @@ -23,16 +94,41 @@ export default async function ProfilePage() { 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. + 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. */}