mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +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 { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
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 { rateLimit } from '@/lib/rate-limit';
|
||||||
import { notifyProjectOwner } from '@/lib/notifications';
|
import { notifyProjectOwner } from '@/lib/notifications';
|
||||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||||
@@ -46,14 +46,15 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { versionId } = await params;
|
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({
|
const version = await db.videoVersion.findUnique({
|
||||||
where: { id: versionId },
|
where: { id: versionId },
|
||||||
include: {
|
include: {
|
||||||
video: {
|
video: {
|
||||||
include: {
|
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 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 shareSession = getShareSessionFromRequest(request, version.video.id);
|
||||||
|
|
||||||
const shareAccess = shareSession
|
const shareAccess = shareSession
|
||||||
@@ -191,6 +192,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { versionId } = await params;
|
const { versionId } = await params;
|
||||||
|
const userId = session?.user?.id;
|
||||||
|
|
||||||
const version = await db.videoVersion.findUnique({
|
const version = await db.videoVersion.findUnique({
|
||||||
where: { id: versionId },
|
where: { id: versionId },
|
||||||
@@ -199,11 +201,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
include: {
|
include: {
|
||||||
project: {
|
project: {
|
||||||
include: {
|
include: {
|
||||||
workspace: {
|
...projectAccessInclude(userId),
|
||||||
select: {
|
// workspace.select is already included by projectAccessInclude;
|
||||||
ownerId: true,
|
// 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 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 shareSession = getShareSessionFromRequest(request, version.video.id);
|
||||||
|
|
||||||
const shareAccess = shareSession
|
const shareAccess = shareSession
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
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 { apiErrors, successResponse } from '@/lib/api-response';
|
||||||
import { rateLimit } from '@/lib/rate-limit';
|
import { rateLimit } from '@/lib/rate-limit';
|
||||||
|
|
||||||
@@ -17,11 +17,12 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
|
|
||||||
const { videoId } = await params;
|
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({
|
const video = await db.video.findUnique({
|
||||||
where: { id: videoId },
|
where: { id: videoId },
|
||||||
include: {
|
include: {
|
||||||
project: true,
|
project: { include: projectAccessInclude(userId) },
|
||||||
versions: {
|
versions: {
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
take: 1,
|
take: 1,
|
||||||
@@ -33,8 +34,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
return apiErrors.notFound('Video');
|
return apiErrors.notFound('Video');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check access including workspace membership
|
const access = computeProjectAccess(video.project, userId);
|
||||||
const access = await checkProjectAccess(video.project, session?.user?.id);
|
|
||||||
|
|
||||||
if (!access.hasAccess) {
|
if (!access.hasAccess) {
|
||||||
return apiErrors.forbidden('Access denied');
|
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.
|
// 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.
|
// 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({
|
const video = await db.video.findUnique({
|
||||||
where: { id: videoId },
|
where: { id: videoId },
|
||||||
include: {
|
include: {
|
||||||
project: true,
|
project: { include: projectAccessInclude(userId) },
|
||||||
versions: {
|
versions: {
|
||||||
where: versionId ? { id: versionId } : { isActive: true },
|
where: versionId ? { id: versionId } : { isActive: true },
|
||||||
take: 1,
|
take: 1,
|
||||||
@@ -109,7 +111,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
return apiErrors.notFound('Video');
|
return apiErrors.notFound('Video');
|
||||||
}
|
}
|
||||||
|
|
||||||
const access = await checkProjectAccess(video.project, session?.user?.id);
|
const access = computeProjectAccess(video.project, userId);
|
||||||
if (!access.hasAccess) {
|
if (!access.hasAccess) {
|
||||||
return apiErrors.forbidden('Access denied');
|
return apiErrors.forbidden('Access denied');
|
||||||
}
|
}
|
||||||
|
|||||||
+102
@@ -90,6 +90,108 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
|||||||
|
|
||||||
type ProjectAccessIntent = 'view' | 'manage' | 'delete';
|
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
|
// Helper to check project access including workspace membership
|
||||||
export async function checkProjectAccess(
|
export async function checkProjectAccess(
|
||||||
project: { id: string; ownerId: string; workspaceId: string; visibility: string },
|
project: { id: string; ownerId: string; workspaceId: string; visibility: string },
|
||||||
|
|||||||
Reference in New Issue
Block a user