300 lines
11 KiB
TypeScript
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;
|
|
}
|
|
}
|