Organize/lib/actions/account-links.ts

300 lines
11 KiB
TypeScript

"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<AccountLinkActionResult> {
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<AccountLinkActionResult> {
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<AccountLinkActionResult> {
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<AccountLinkActionResult> {
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<AccountLinkActionResult> {
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<AccountLinkActionResult> {
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<ToggleResult> {
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;
}
}