Files
vfxreview/lib/review-auth.ts
T
twotalesanimation 28e8c47907 feat(client-portal): let a reviewer edit and delete their own notes
Notes left through a review link were write-once. A client who mistyped a
timecode or changed their mind had no way to correct it and had to add a second
note contradicting the first, which the studio then had to reconcile by hand.

PATCH and DELETE on comment/[commentId] cover both. Only the reviewer's own
notes are touchable: the studio's comments and those of every other review
session are read-only through a link, whatever the holder sends. Ownership is
re-checked server-side on every write, so the canEdit flag now returned by the
versions route only decides whether to offer the controls and grants nothing.

Refusals are 404 rather than 403 throughout, since a link holder has no business
learning which comment ids exist outside their own notes.

Only the text is editable. The frame a note is pinned to is what the studio
works from, so moving it would silently repoint feedback someone may already
have actioned; a note about a different frame is a new note. Deleting a drawing
note also removes its strokes, mirroring deleteComment in actions/comments.ts,
and the page reloads the player afterwards so the frame does not keep showing
marks that no longer exist. Delete goes through a confirmation that shows the
note and says plainly that it disappears for the studio too.

Worth knowing before this reaches a show: everyone holding the same review link
is the same author. A ReviewSession with no email resolves to a token-derived
guest user, so one link sent to three people gives all three one identity and
any of them can now edit or delete the others' notes. That model is not new,
which is why their comments already showed as one name, but edit and delete is
the first feature where it grants real power rather than muddling attribution.
Per-reviewer links with email set are the fix if that is not wanted, and that is
a change to how links are issued rather than to this code. The scope is
documented on reviewClientEmail so the next reader meets it before it surprises
them.

getOrCreateClientUser was copy-pasted into the comment and approve routes with
the fallback-email expression spelled out in three places. It is one set of
helpers in review-auth.ts now, including findReviewUser, a read-only variant so
that merely opening a review link does not conjure a User row just to answer
"is this theirs?", and ownsComment, the single rule shared by the route that
offers the controls and the route that enforces them.

Nine tests cover that rule directly: internal users' notes stay read-only, a
session that has never posted owns nothing, and an authorless comment — one
whose User was deleted, since Comment.authorId is SetNull — cannot be claimed by
anyone. The routes themselves are not covered, so exercise edit and delete
against a real review link before handing one to a client.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:17:40 +02:00

94 lines
3.2 KiB
TypeScript

import { NextRequest } from "next/server";
import { db } from "@/lib/db";
import crypto from "crypto";
import type { ReviewSession } from "@prisma/client";
export type TokenValidation =
| { type: "ok"; session: ReviewSession }
| { type: "invalid" }
| { type: "requiresPassword" };
export function makeUnlockCookieValue(token: string): string {
const secret = process.env.AUTH_SECRET ?? "fallback-secret";
return crypto.createHmac("sha256", secret).update(token).digest("hex");
}
export async function validateReviewToken(
token: string,
req: NextRequest
): Promise<TokenValidation> {
const session = await db.reviewSession.findUnique({ where: { token } });
if (!session || !session.isActive) return { type: "invalid" };
if (session.expiresAt && session.expiresAt < new Date()) return { type: "invalid" };
if (session.passwordHash) {
const cookieName = `rsauth_${token}`;
const cookieValue = req.cookies.get(cookieName)?.value;
const expected = makeUnlockCookieValue(token);
if (!cookieValue || cookieValue !== expected) {
return { type: "requiresPassword" };
}
}
return { type: "ok", session };
}
/**
* The email a review session writes its activity under.
*
* A session with no email on it still needs a stable identity so its comments
* group under one author and stay attributable to that link — hence the
* token-derived fallback. Note what this means for ownership: everyone holding
* the same review link *is* the same author. That is the intended scope, since
* the link is the credential.
*/
export function reviewClientEmail(session: ReviewSession, token: string): string {
return session.email ?? `client+${token.slice(0, 8)}@review.external`;
}
/** The guest User a review session posts as, creating it on first activity. */
export async function getOrCreateReviewUser(session: ReviewSession, token: string) {
const email = reviewClientEmail(session, token);
const existing = await db.user.findUnique({ where: { email } });
if (existing) return existing;
return db.user.create({
data: {
email,
name: session.label ?? email.split("@")[0],
role: "CLIENT",
isActive: true,
},
});
}
/**
* The guest User a review session posts as, or null if it has never posted.
*
* Read-only counterpart to getOrCreateReviewUser, for the paths that only need
* to ask "is this theirs?" — merely opening a review link should not conjure a
* User row.
*/
export async function findReviewUser(session: ReviewSession, token: string) {
return db.user.findUnique({
where: { email: reviewClientEmail(session, token) },
select: { id: true },
});
}
/**
* May this review session change or remove this comment?
*
* Only its own. The studio's notes and those of every other review session are
* read-only through a client link, and an authorless comment (its User was
* deleted) belongs to nobody and so cannot be claimed.
*
* Shared by the listing route, which uses it to decide whether to offer the
* controls, and the edit/delete route, which enforces it.
*/
export function ownsComment(
comment: { authorId: string | null },
reviewUserId: string | null | undefined
): boolean {
return !!reviewUserId && !!comment.authorId && comment.authorId === reviewUserId;
}