"use server"; import { randomBytes } from "node:crypto"; import { revalidatePath } from "next/cache"; import { AuthError } from "next-auth"; import { prisma } from "@/lib/db"; import { requireUserId } from "@/lib/auth-helpers"; import { EmailSchema } from "@/lib/validation/auth"; import { Prisma } from "@/lib/generated/prisma/client"; import { Role } from "@/lib/generated/prisma/enums"; import { signIn, signOut } from "@/auth"; /** Shared shape for the link-management actions: `error` on failure, a * plain `{}` (or `ok`) on success. Kept as one flat object -- a discriminated * union with an index signature confuses React types on the client side. */ export type AccountLinkActionResult = { error?: string; ok?: true }; /** Re-render the Profile page after any change to the linking state. * (The (app) layout doesn't show link state, so /profile alone suffices.) */ function revalidateProfile() { revalidatePath("/profile"); } /** The canonical "does a confirmed link already exist between these two * accounts?" check -- order-independent, since a link is stored once per * pair in either direction. */ function linkedPairWhere(userId: string, otherUserId: string) { return { OR: [ { userId, linkedUserId: otherUserId }, { userId: otherUserId, linkedUserId: userId }, ], }; } /** * Sends (or silently refreshes) a link request to another account. The * recipient sees it in their Profile page's "Requested Account Link" * section and can create, deny, or deny-and-block it. Returns * `{ error }` on failure; the UI then shows "awaiting confirmation". */ export async function requestAccountLink(email: string): Promise { const userId = await requireUserId(); const parsed = EmailSchema.safeParse(email); if (!parsed.success) { return { error: parsed.error.issues[0]?.message ?? "Enter a valid email address." }; } const target = await prisma.user.findUnique({ where: { email: parsed.data } }); if (!target) { return { error: "No account with that email address on this server." }; } if (target.id === userId) { return { error: "That's the account you're signed in as." }; } const existingLink = await prisma.accountLink.findFirst({ where: linkedPairWhere(userId, target.id), }); if (existingLink) { return { error: "Those accounts are already linked." }; } // "Deny and Block Account Link" on their side stops new requests from // this account to theirs. const blocked = await prisma.accountLinkBlock.findUnique({ where: { fromUserId_toUserId: { fromUserId: userId, toUserId: target.id } }, }); if (blocked) { return { error: "That account has blocked link requests from you." }; } // Upsert, not create: the composite unique makes re-requesting while a // pending request already exists a no-op refresh rather than a 500. await prisma.accountLinkRequest.upsert({ where: { fromUserId_toUserId: { fromUserId: userId, toUserId: target.id } }, create: { fromUserId: userId, toUserId: target.id }, update: {}, }); revalidateProfile(); return { ok: true }; } /** * The recipient's "Create Account Link": confirms a pending request and * creates the (single, order-independent) link between the two accounts. * Also closes any remaining pending requests between the pair, in either * direction -- once linked, neither side needs to act on them anymore. */ export async function createAccountLink(requestId: string): Promise { const userId = await requireUserId(); const request = await prisma.accountLinkRequest.findUnique({ where: { id: requestId }, }); // Only the *recipient* can confirm; the requester has no buttons here. if (!request || request.toUserId !== userId) { return { error: "That link request no longer exists." }; } const requesterId = request.fromUserId; if (requesterId === userId) { return { error: "That link request no longer exists." }; } try { await prisma.$transaction([ prisma.accountLink.create({ // Canonical order: the smaller id first. Makes the pair unique // regardless of which direction the confirming request came in. data: { userId: [userId, requesterId].sort()[0], linkedUserId: [userId, requesterId].sort()[1], }, }), prisma.accountLinkRequest.deleteMany({ where: { OR: [ { fromUserId: userId, toUserId: requesterId }, { fromUserId: requesterId, toUserId: userId }, ], }, }), ]); } catch (error) { // A concurrent confirm (both sides clicked at once) can win the // unique pair -- treat as success: the link exists, which is the // outcome both sides wanted. if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { return { ok: true }; } throw error; } revalidateProfile(); return { ok: true }; } /** * The recipient's "Deny Account Link": drops the pending request. The * requester may try again later (there's no block). */ export async function denyAccountLink(requestId: string): Promise { const userId = await requireUserId(); const request = await prisma.accountLinkRequest.findUnique({ where: { id: requestId } }); if (!request || request.toUserId !== userId) { return { error: "That link request no longer exists." }; } await prisma.accountLinkRequest.delete({ where: { id: requestId } }); revalidateProfile(); return { ok: true }; } /** * The recipient's "Deny and Block Account Link": denies the request and * blocks any future link requests from that account to this one. The * block shows up in the "Blocked Account Link Requests" section, where it * can be lifted again. */ export async function denyAndBlockAccountLink(requestId: string): Promise { const userId = await requireUserId(); const request = await prisma.accountLinkRequest.findUnique({ where: { id: requestId } }); if (!request || request.toUserId !== userId) { return { error: "That link request no longer exists." }; } const requesterId = request.fromUserId; await prisma.$transaction([ prisma.accountLinkRequest.delete({ where: { id: requestId } }), // Upsert: a block may already exist from an earlier denial -- keep it, // don't fail on it. prisma.accountLinkBlock.upsert({ where: { fromUserId_toUserId: { fromUserId: requesterId, toUserId: userId } }, create: { fromUserId: requesterId, toUserId: userId }, update: {}, }), ]); revalidateProfile(); return { ok: true }; } /** * "Remove Link" from the Linked Accounts list: either account in the pair * can remove the link. Removes only the link itself -- pending requests * and blocks between the two accounts are left as-is (a re-request simply * restarts the flow). */ export async function removeAccountLink(linkId: string): Promise { const userId = await requireUserId(); const link = await prisma.accountLink.findUnique({ where: { id: linkId } }); if (!link || (link.userId !== userId && link.linkedUserId !== userId)) { return { error: "That account link no longer exists." }; } await prisma.accountLink.delete({ where: { id: linkId } }); revalidateProfile(); return { ok: true }; } /** * Lifts a block the signed-in account issued ("Allow requests" in the * Blocked section), letting that account send link requests again. */ export async function unblockAccountLinkRequests(blockId: string): Promise { const userId = await requireUserId(); const block = await prisma.accountLinkBlock.findUnique({ where: { id: blockId } }); if (!block || block.toUserId !== userId) { return { error: "That block no longer exists." }; } await prisma.accountLinkBlock.delete({ where: { id: blockId } }); revalidateProfile(); return { ok: true }; } /** * The "Toggle" button: verifies the signed-in user is actually linked to * the given account, mints a one-time, short-lived token for the * "account-switch" provider (auth.ts), signs the current session out, and * signs in as the linked account -- all server-side, so the linked * account's password is never needed. The client then navigates to the * returned URL with the new session cookie. * * The token rides nowhere the user can craft it: 256 bits of randomness, * single-use (consumed in the provider's authorize()), and it expires * within a minute, so a copy in logs or history is worthless. */ const SWITCH_TOKEN_TTL_MS = 60_000; export type ToggleResult = { url?: string; error?: string }; export async function toggleAccount(linkedUserId: string): Promise { const userId = await requireUserId(); if (linkedUserId === userId) { return { error: "That's the account you're already signed in as." }; } const link = await prisma.accountLink.findFirst({ where: linkedPairWhere(userId, linkedUserId), }); if (!link) { return { error: "Those accounts aren't linked." }; } // Check before signing the user out: a PENDING (or deleted) target can // never hold a session (the account-switch provider rejects it), so // failing here keeps the user signed in to their own account instead of // stranding them logged out. const target = await prisma.user.findUnique({ where: { id: linkedUserId }, select: { role: true }, }); if (!target || target.role === Role.PENDING) { return { error: "That account can't sign in right now -- it may be pending approval." }; } const token = randomBytes(32).toString("hex"); await prisma.accountSwitchToken.create({ data: { token, requestedBy: userId, targetUserId: linkedUserId, expiresAt: new Date(Date.now() + SWITCH_TOKEN_TTL_MS), }, }); // Drop the current session first, then take the linked account's. Both // responses set the same session cookie, so the second write wins and the // browser is left exactly one signed-in account -- the toggled-to one. await signOut({ redirect: false }); try { // redirect:false: signIn() commits the new session cookie through // next/headers and returns the redirect URL as a string (see // next-auth/lib/actions.js) instead of throwing a framework redirect -- // the client does the navigation so it can toast on failure. const url = await signIn("account-switch", { token, redirectTo: "/", redirect: false }); return { url }; } catch (error) { // authorize() throws SwitchAccountSignin (an AuthError) for a // missing/expired/consumed token or a PENDING target account. if (error instanceof AuthError) { return { error: "Couldn't switch to that account. Check that it's still linked and active." }; } throw error; } }