mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
Add admin-only comment resolution controls
This commit is contained in:
@@ -8,6 +8,7 @@ import { validateShareLinkAccess } from '@/lib/share-links';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { getGuestIdentityFromRequest } from '@/lib/guest-identity';
|
||||
import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
|
||||
|
||||
type RouteParams = { params: Promise<{ commentId: string }> };
|
||||
|
||||
@@ -156,7 +157,10 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
const userId = session?.user?.id ?? null;
|
||||
const isOwner = userId === project.ownerId;
|
||||
const isAuthor = !!userId && comment.authorId === userId;
|
||||
const isMember = !!userId && project.members.some((member) => member.userId === userId);
|
||||
const projectMembership = userId
|
||||
? project.members.find((member) => member.userId === userId) ?? null
|
||||
: null;
|
||||
const isProjectAdmin = projectMembership?.role === ProjectMemberRole.ADMIN;
|
||||
const guestIdentityId = !userId ? getGuestIdentityFromRequest(request) : null;
|
||||
const isGuestAuthor = !userId
|
||||
&& !comment.authorId
|
||||
@@ -164,9 +168,9 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
&& guestIdentityId === comment.guestIdentityId;
|
||||
const canEditOwnContent = isAuthor || isGuestAuthor;
|
||||
|
||||
// Check workspace membership for resolve permissions
|
||||
let isWorkspaceMember = false;
|
||||
if (!isOwner && !isMember && userId) {
|
||||
// Check workspace role for resolve permissions.
|
||||
let workspaceRole: WorkspaceMemberRole | 'OWNER' | null = null;
|
||||
if (!isOwner && userId) {
|
||||
const wsMember = await db.workspaceMember.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
@@ -179,8 +183,14 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
where: { id: project.workspaceId },
|
||||
select: { ownerId: true },
|
||||
});
|
||||
isWorkspaceMember = !!wsMember || wsOwner?.ownerId === userId;
|
||||
if (wsOwner?.ownerId === userId) {
|
||||
workspaceRole = 'OWNER';
|
||||
} else if (wsMember) {
|
||||
workspaceRole = wsMember.role;
|
||||
}
|
||||
}
|
||||
const isWorkspaceAdmin = workspaceRole === 'OWNER' || workspaceRole === WorkspaceMemberRole.ADMIN;
|
||||
const canResolveComment = isOwner || isProjectAdmin || isWorkspaceAdmin;
|
||||
|
||||
if (!userId && !isGuestAuthor) {
|
||||
const shareSession = getShareSessionFromRequest(request, comment.version.video.id);
|
||||
@@ -205,8 +215,8 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
|
||||
// Owner, author, members, or workspace members can resolve/unresolve
|
||||
if (isResolved !== undefined && !isOwner && !canEditOwnContent && !isMember && !isWorkspaceMember) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
if (isResolved !== undefined && !canResolveComment) {
|
||||
return apiErrors.forbidden('Only admins can resolve comments');
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
|
||||
@@ -108,6 +108,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
currentUserName: session?.user?.name || null,
|
||||
canDownload: access.hasAccess,
|
||||
canManageTags: access.canEdit,
|
||||
canResolveComments: access.canEdit,
|
||||
});
|
||||
|
||||
return withCacheControl(response, 'private, no-cache');
|
||||
|
||||
@@ -191,6 +191,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
canComment: canCommentWithMembership || canCommentWithShareLink,
|
||||
canDownload: canDownloadWithMembership || canDownloadWithShareLink,
|
||||
canManageTags: access.canEdit,
|
||||
canResolveComments: access.canEdit,
|
||||
});
|
||||
|
||||
return withCacheControl(response, 'private, no-cache');
|
||||
|
||||
@@ -167,6 +167,7 @@ interface VideoData {
|
||||
canComment?: boolean;
|
||||
canDownload?: boolean;
|
||||
canManageTags?: boolean;
|
||||
canResolveComments?: boolean;
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
@@ -511,6 +512,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
// Determine current user info for permission checks and comment display
|
||||
const currentUserId = video?.currentUserId || null;
|
||||
const currentUserName = video?.currentUserName || null;
|
||||
const canResolveComments = !!video?.canResolveComments;
|
||||
|
||||
const apiBasePath = mode === 'dashboard'
|
||||
? `/api/projects/${propProjectId}/videos/${videoId}`
|
||||
@@ -1959,6 +1961,11 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
|
||||
const handleResolveComment = useCallback(
|
||||
async (commentId: string, currentlyResolved: boolean) => {
|
||||
if (!video?.canResolveComments) {
|
||||
toast.error('Only admins can resolve comments');
|
||||
return;
|
||||
}
|
||||
|
||||
isMutatingRef.current = true;
|
||||
setVideo((prev) => {
|
||||
if (!prev) return prev;
|
||||
@@ -2025,7 +2032,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
isMutatingRef.current = false;
|
||||
}
|
||||
},
|
||||
[activeVersionId]
|
||||
[activeVersionId, video?.canResolveComments]
|
||||
);
|
||||
|
||||
const handleReplyComment = useCallback(async (parentId: string, voiceData?: { url: string; duration: number }, imageData?: { url: string }) => {
|
||||
@@ -3605,20 +3612,22 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
{formatTime(comment.timestamp)}
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() =>
|
||||
handleResolveComment(comment.id, comment.isResolved)
|
||||
}
|
||||
>
|
||||
{comment.isResolved ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
{canResolveComments && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() =>
|
||||
handleResolveComment(comment.id, comment.isResolved)
|
||||
}
|
||||
>
|
||||
{comment.isResolved ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{canManageComment && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
Reference in New Issue
Block a user