mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
feat(auth): enhance project access handling with pre-fetched data and new utility functions
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { auth, computeProjectAccess, projectAccessInclude } from '@/lib/auth';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { notifyProjectOwner } from '@/lib/notifications';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
@@ -46,14 +46,15 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const session = await auth();
|
||||
const { versionId } = await params;
|
||||
const userId = session?.user?.id;
|
||||
|
||||
// Get version with project access info
|
||||
// Get version with project access data pre-fetched in the same query
|
||||
const version = await db.videoVersion.findUnique({
|
||||
where: { id: versionId },
|
||||
include: {
|
||||
video: {
|
||||
include: {
|
||||
project: true,
|
||||
project: { include: projectAccessInclude(userId) },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -64,7 +65,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
|
||||
const project = version.video.project;
|
||||
const access = await checkProjectAccess(project, session?.user?.id);
|
||||
const access = computeProjectAccess(project, userId);
|
||||
const shareSession = getShareSessionFromRequest(request, version.video.id);
|
||||
|
||||
const shareAccess = shareSession
|
||||
@@ -191,6 +192,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
const session = await auth();
|
||||
const { versionId } = await params;
|
||||
const userId = session?.user?.id;
|
||||
|
||||
const version = await db.videoVersion.findUnique({
|
||||
where: { id: versionId },
|
||||
@@ -199,11 +201,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
include: {
|
||||
project: {
|
||||
include: {
|
||||
workspace: {
|
||||
select: {
|
||||
ownerId: true,
|
||||
},
|
||||
},
|
||||
...projectAccessInclude(userId),
|
||||
// workspace.select is already included by projectAccessInclude;
|
||||
// ownerId is present on workspace via projectAccessInclude
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -216,7 +216,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
|
||||
const project = version.video.project;
|
||||
const access = await checkProjectAccess(project, session?.user?.id);
|
||||
const access = computeProjectAccess(project, userId);
|
||||
const shareSession = getShareSessionFromRequest(request, version.video.id);
|
||||
|
||||
const shareAccess = shareSession
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { auth, computeProjectAccess, projectAccessInclude } from '@/lib/auth';
|
||||
import { apiErrors, successResponse } from '@/lib/api-response';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
|
||||
@@ -17,11 +17,12 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
const { videoId } = await params;
|
||||
|
||||
// Get the video and its active version
|
||||
// Get the video and its active version (project access data pre-fetched in same query)
|
||||
const userId = session.user.id;
|
||||
const video = await db.video.findUnique({
|
||||
where: { id: videoId },
|
||||
include: {
|
||||
project: true,
|
||||
project: { include: projectAccessInclude(userId) },
|
||||
versions: {
|
||||
where: { isActive: true },
|
||||
take: 1,
|
||||
@@ -33,8 +34,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.notFound('Video');
|
||||
}
|
||||
|
||||
// Check access including workspace membership
|
||||
const access = await checkProjectAccess(video.project, session?.user?.id);
|
||||
const access = computeProjectAccess(video.project, userId);
|
||||
|
||||
if (!access.hasAccess) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
@@ -94,10 +94,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
// Always load the requested video and validate access before writing progress.
|
||||
// If versionId is provided, verify it belongs to this video; otherwise resolve active version.
|
||||
// Project access data is pre-fetched in the same query — no extra round-trips.
|
||||
const userId = session.user.id;
|
||||
const video = await db.video.findUnique({
|
||||
where: { id: videoId },
|
||||
include: {
|
||||
project: true,
|
||||
project: { include: projectAccessInclude(userId) },
|
||||
versions: {
|
||||
where: versionId ? { id: versionId } : { isActive: true },
|
||||
take: 1,
|
||||
@@ -109,7 +111,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.notFound('Video');
|
||||
}
|
||||
|
||||
const access = await checkProjectAccess(video.project, session?.user?.id);
|
||||
const access = computeProjectAccess(video.project, userId);
|
||||
if (!access.hasAccess) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
+102
@@ -90,6 +90,108 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
|
||||
type ProjectAccessIntent = 'view' | 'manage' | 'delete';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fast-path: pre-fetch access data alongside any existing DB query so that
|
||||
// computeProjectAccess() can resolve the result with zero extra round-trips.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Prisma include fragment to attach to any project fetch. */
|
||||
export function projectAccessInclude(userId: string | undefined) {
|
||||
return {
|
||||
workspace: {
|
||||
select: {
|
||||
id: true,
|
||||
ownerId: true,
|
||||
owner: {
|
||||
select: {
|
||||
subscriptionStatus: true,
|
||||
trialEndsAt: true,
|
||||
stripeCurrentPeriodEnd: true,
|
||||
billingAccessEndedAt: true,
|
||||
},
|
||||
},
|
||||
members: userId
|
||||
? { where: { userId }, take: 1, orderBy: { createdAt: 'asc' as const }, select: { role: true } }
|
||||
: { take: 0, select: { role: true } },
|
||||
},
|
||||
},
|
||||
members: userId
|
||||
? { where: { userId }, take: 1, orderBy: { createdAt: 'asc' as const }, select: { role: true } }
|
||||
: { take: 0, select: { role: true } },
|
||||
};
|
||||
}
|
||||
|
||||
type ProjectAccessIncludes = ReturnType<typeof projectAccessInclude>;
|
||||
type WorkspaceForAccess = ProjectAccessIncludes['workspace']['select'] extends object
|
||||
? {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
owner: Parameters<typeof hasBillingAccess>[0] | null;
|
||||
members: Array<{ role: WorkspaceMemberRole }>;
|
||||
}
|
||||
: never;
|
||||
|
||||
export type EnrichedProjectForAccess = {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
workspaceId: string;
|
||||
visibility: string;
|
||||
workspace: WorkspaceForAccess;
|
||||
members: Array<{ role: ProjectMemberRole }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure access computation — no DB queries.
|
||||
* Use after fetching a project with `projectAccessInclude(userId)`.
|
||||
*/
|
||||
export function computeProjectAccess(
|
||||
project: EnrichedProjectForAccess,
|
||||
userId: string | undefined,
|
||||
) {
|
||||
const isOwner = userId === project.ownerId;
|
||||
const isPublic = project.visibility === 'PUBLIC';
|
||||
|
||||
const projectMember = project.members[0] ?? null;
|
||||
const isProjectMember = !!projectMember;
|
||||
const isProjectAdmin = projectMember?.role === ProjectMemberRole.ADMIN;
|
||||
|
||||
const workspaceOwnerBillingAccess = project.workspace.owner
|
||||
? hasBillingAccess(project.workspace.owner)
|
||||
: false;
|
||||
|
||||
let workspaceRole: WorkspaceMemberRole | 'OWNER' | null = null;
|
||||
if (userId === project.workspace.ownerId) {
|
||||
workspaceRole = 'OWNER';
|
||||
} else {
|
||||
const wsMember = project.workspace.members[0] ?? null;
|
||||
if (wsMember) workspaceRole = wsMember.role;
|
||||
}
|
||||
|
||||
const isWorkspaceMember = !!workspaceRole;
|
||||
const isWorkspaceAdmin =
|
||||
workspaceRole === WorkspaceMemberRole.ADMIN || workspaceRole === 'OWNER';
|
||||
|
||||
const hasAccess =
|
||||
workspaceOwnerBillingAccess &&
|
||||
(isOwner || isProjectMember || isPublic || isWorkspaceMember);
|
||||
const canEdit =
|
||||
workspaceOwnerBillingAccess && (isOwner || isProjectAdmin || isWorkspaceAdmin);
|
||||
const canDelete =
|
||||
workspaceOwnerBillingAccess && (isOwner || workspaceRole === 'OWNER');
|
||||
|
||||
return {
|
||||
isOwner,
|
||||
isProjectMember,
|
||||
isProjectAdmin,
|
||||
isWorkspaceMember,
|
||||
isWorkspaceAdmin,
|
||||
hasAccess,
|
||||
canEdit,
|
||||
canDelete,
|
||||
ownerBillingActive: workspaceOwnerBillingAccess,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to check project access including workspace membership
|
||||
export async function checkProjectAccess(
|
||||
project: { id: string; ownerId: string; workspaceId: string; visibility: string },
|
||||
|
||||
Reference in New Issue
Block a user