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 (
- 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.