-
+ {/* Row 1: Work Item Type + Priority + Actions */}
+
-
{task.type && (
)}
-
{task.priority && (
)}
-
- {task.labels && task.labels.length > 0 && (
-
- {task.labels.slice(0, 2).map((label, index) => {
- const customLabel = customLabels.find(l => l.name === label);
- return (
-
- );
- })}
-
-
- )}
-
-
-
+ {/* Row 2-3: Labels (max 2 rows with overflow +N) */}
+ {task.labels && task.labels.length > 0 && (
+
+ {(() => {
+ const maxVisible = 4; // Show up to 4 labels across 2 rows
+ const visibleLabels = task.labels.slice(0, maxVisible);
+ const hiddenCount = task.labels.length - maxVisible;
+ return (
+ <>
+ {visibleLabels.map((label, index) => {
+ const customLabel = customLabels.find(l => l.name === label);
+ return (
+
+ );
+ })}
+ {hiddenCount > 0 && (
+
+ +{hiddenCount}
+
+ )}
+ >
+ );
+ })()}
+
+ )}
+
{task.flagged && (
diff --git a/src/features/tasks/components/task-actions.tsx b/src/features/tasks/components/task-actions.tsx
index 66e312be..37f67724 100644
--- a/src/features/tasks/components/task-actions.tsx
+++ b/src/features/tasks/components/task-actions.tsx
@@ -1,5 +1,6 @@
-import { ExternalLink, TrashIcon, FlagIcon } from "lucide-react";
+import { ExternalLink, TrashIcon, FlagIcon, Share2 } from "lucide-react";
import { useRouter } from "next/navigation";
+import { toast } from "sonner";
import { useConfirm } from "@/hooks/use-confirm";
import {
@@ -76,6 +77,27 @@ export const TaskActions = ({
router.push(`/workspaces/${workspaceId}/tasks/${id}`);
};
+ const onShare = async () => {
+ const url = typeof window !== "undefined"
+ ? `${window.location.origin}/workspaces/${workspaceId}/tasks/${id}`
+ : `/workspaces/${workspaceId}/tasks/${id}`;
+ try {
+ if (navigator.share) {
+ await navigator.share({ title: "Work Item", url });
+ } else {
+ await navigator.clipboard.writeText(url);
+ toast.success("Link copied to clipboard");
+ }
+ } catch {
+ try {
+ await navigator.clipboard.writeText(url);
+ toast.success("Link copied to clipboard");
+ } catch {
+ toast.error("Failed to share");
+ }
+ }
+ };
+
const canEditTask = canEdit && (isWorkspaceAdmin || canEditTasksProject || can(PERMISSIONS.WORKITEM_UPDATE));
const canDeleteTask = canDelete && (isWorkspaceAdmin || canDeleteTasksProject || can(PERMISSIONS.WORKITEM_DELETE));
@@ -92,6 +114,13 @@ export const TaskActions = ({
Work Item Details
+
+
+ Share Work Item
+
{canEditTask && (
= {
@@ -488,7 +489,21 @@ const TaskPreviewContent = ({ task, workspaceId, onEdit, onClose, onAttachmentPr
{/* Labels */}
- {/* If we want to support labels, we need LabelSelector. Skipping for now to keep it simpler unless requested, as it requires complex project label fetching */}
+
+ Labels
+ handleUpdate({ labels }) : () => {}}
+ availableLabels={[
+ ...(project?.customLabels?.map((l: { name: string }) => l.name) || []),
+ "Bug", "Feature", "Improvement", "Documentation", "Design", "Research",
+ "Frontend", "Backend",
+ ].filter((v, i, a) => a.indexOf(v) === i)}
+ placeholder="Add label..."
+ className="h-8 text-xs w-full"
+ disabled={!canEdit}
+ />
+
{/* Time Estimate */}
diff --git a/src/features/tasks/components/task-view-switcher.tsx b/src/features/tasks/components/task-view-switcher.tsx
index 2e39a9a7..8284cb04 100644
--- a/src/features/tasks/components/task-view-switcher.tsx
+++ b/src/features/tasks/components/task-view-switcher.tsx
@@ -28,6 +28,7 @@ import EnhancedBacklogScreen from "@/features/sprints/components/enhanced-backlo
import { ProjectSetupOverlay } from "@/features/sprints/components/project-setup-overlay";
import { useGetWorkItems, useGetSprints, SprintStatus, WorkItemStatus, WorkItemPriority, PopulatedWorkItem, useBulkUpdateWorkItems } from "@/features/sprints";
import { CompleteSprintModal } from "@/features/sprints/components/complete-sprint-modal";
+import { CreateWorkItemModal } from "@/features/sprints";
import { useTaskFilters } from "../hooks/use-task-filters";
@@ -403,6 +404,7 @@ export const TaskViewSwitcher = ({
Complete Sprint {setupState.activeSprint.name}
)}
+
@@ -543,6 +545,7 @@ export const TaskViewSwitcher = ({
/>
)
}
+
);
};
diff --git a/src/lib/permissions/resolveUserProjectAccess.ts b/src/lib/permissions/resolveUserProjectAccess.ts
index dfb8d097..be3fc7b0 100644
--- a/src/lib/permissions/resolveUserProjectAccess.ts
+++ b/src/lib/permissions/resolveUserProjectAccess.ts
@@ -257,12 +257,48 @@ export async function resolveUserProjectAccess(
}
const membership = memberships.documents[0];
- // Handle both patterns: inline role enum or roleId reference
- // If role field is set, use it directly; otherwise default to MEMBER
- const role = (membership.role as ProjectMemberRole) || ProjectMemberRole.MEMBER;
+
+ // Handle BOTH patterns for role resolution:
+ // Pattern A: `role` field has ProjectMemberRole enum value ("PROJECT_OWNER", "PROJECT_ADMIN", etc.)
+ // Pattern B: `roleId` references a role document, `roleName` is denormalized ("OWNER", "ADMIN", etc.)
+ let role: ProjectMemberRole = ProjectMemberRole.MEMBER; // safe default
+
+ // 1) Try the `role` enum field first
+ const rawRole = membership.role as string | undefined;
+ if (rawRole && Object.values(ProjectMemberRole).includes(rawRole as ProjectMemberRole)) {
+ role = rawRole as ProjectMemberRole;
+ }
+ // 2) Fallback: map `roleName` to ProjectMemberRole
+ else {
+ const roleName = (membership as unknown as { roleName?: string }).roleName;
+ if (roleName) {
+ const roleNameMap: Record
= {
+ "OWNER": ProjectMemberRole.PROJECT_OWNER,
+ "ADMIN": ProjectMemberRole.PROJECT_ADMIN,
+ "MEMBER": ProjectMemberRole.MEMBER,
+ "VIEWER": ProjectMemberRole.VIEWER,
+ };
+ role = roleNameMap[roleName.toUpperCase()] || ProjectMemberRole.MEMBER;
+ }
+ }
- // 2. Get role-based permissions
- const rolePermissions = ROLE_PERMISSIONS[role] || [];
+ // Get role-based permissions from the enum mapping
+ let rolePermissions: string[] = [...(ROLE_PERMISSIONS[role] || [])];
+
+ // Also fetch permissions from the roleId-referenced role document (if it exists)
+ const roleId = (membership as unknown as { roleId?: string }).roleId;
+ if (roleId) {
+ try {
+ const { PROJECT_ROLES_ID } = await import("@/config");
+ const roleDoc = await databases.getDocument(DATABASE_ID, PROJECT_ROLES_ID, roleId);
+ const docPermissions = roleDoc.permissions as string[] | undefined;
+ if (docPermissions && Array.isArray(docPermissions)) {
+ rolePermissions = [...new Set([...rolePermissions, ...docPermissions])];
+ }
+ } catch {
+ // Role document fetch failed - continue with enum-based permissions
+ }
+ }
// 3. Get team memberships
const teamMemberships = await databases.listDocuments(
@@ -373,8 +409,8 @@ export function hasProjectPermission(
access: ProjectAccessResult,
permission: ProjectPermissionKey | string
): boolean {
- // Owner always has access
- if (access.isOwner) return true;
+ // Owner or Admin always has access
+ if (access.isOwner || access.isAdmin) return true;
return access.permissions.includes(permission);
}
diff --git a/src/lib/project-rbac.ts b/src/lib/project-rbac.ts
index b2e84291..e7f06c41 100644
--- a/src/lib/project-rbac.ts
+++ b/src/lib/project-rbac.ts
@@ -3,6 +3,7 @@ import { DATABASE_ID, PROJECT_MEMBERS_ID, PROJECT_ROLES_ID, MEMBERS_ID, PROJECT_
import { ProjectMember, ProjectRole, ProjectPermissionResult } from "@/features/project-members/types";
import { PROJECT_PERMISSIONS, DEFAULT_PROJECT_ROLES } from "./project-permissions";
import { MemberRole } from "@/features/members/types";
+import { ROLE_PERMISSIONS } from "@/lib/permissions/resolveUserProjectAccess";
/**
* @deprecated This module is deprecated.
@@ -145,6 +146,60 @@ export async function getProjectPermissionResult(
projectId: string
): Promise {
try {
+ // --- Workspace Admin / Org Admin Override ---
+ // Check if user is a workspace admin or org admin and grant full permissions
+ try {
+ const { PROJECTS_ID, WORKSPACES_ID, ORGANIZATION_MEMBERS_ID } = await import("@/config");
+ const project = await databases.getDocument(DATABASE_ID, PROJECTS_ID, projectId);
+ const workspaceId = project.workspaceId;
+
+ // Check workspace admin
+ const isWsAdmin = await checkWorkspaceAdmin(databases, workspaceId, userId);
+ if (isWsAdmin) {
+ return {
+ projectId,
+ userId,
+ permissions: Object.values(PROJECT_PERMISSIONS),
+ roles: [{ roleId: "ws-admin", roleName: "Workspace Admin", teamId: "", teamName: "" }],
+ isProjectAdmin: true,
+ };
+ }
+
+ // Check org admin
+ let organizationId: string | null = null;
+ try {
+ const workspace = await databases.getDocument(DATABASE_ID, WORKSPACES_ID, workspaceId);
+ organizationId = workspace.organizationId || null;
+ } catch { /* ignore */ }
+
+ if (organizationId) {
+ try {
+ const orgMembers = await databases.listDocuments(
+ DATABASE_ID,
+ ORGANIZATION_MEMBERS_ID,
+ [
+ Query.equal("organizationId", organizationId),
+ Query.equal("userId", userId),
+ Query.equal("status", "ACTIVE"),
+ ]
+ );
+ if (orgMembers.total > 0) {
+ const orgRole = orgMembers.documents[0].role;
+ if (["OWNER", "ADMIN", "MODERATOR"].includes(orgRole)) {
+ return {
+ projectId,
+ userId,
+ permissions: Object.values(PROJECT_PERMISSIONS),
+ roles: [{ roleId: "org-admin", roleName: `Org ${orgRole}`, teamId: "", teamName: "" }],
+ isProjectAdmin: true,
+ };
+ }
+ }
+ } catch { /* ignore */ }
+ }
+ } catch { /* project fetch failed, continue with member-based resolution */ }
+
+ // --- Standard member-based resolution ---
// Fetch all project memberships
let memberships;
try {
@@ -177,15 +232,22 @@ export async function getProjectPermissionResult(
};
}
- // Collect role IDs
- const roleIds = [...new Set(memberships.documents.map((m) => m.roleId))];
+ // Collect role IDs (filter out empty/undefined)
+ const roleIds = [...new Set(memberships.documents.map((m) => m.roleId).filter(Boolean))];
- // Fetch roles
- const roles = await databases.listDocuments(
- DATABASE_ID,
- PROJECT_ROLES_ID,
- [Query.equal("$id", roleIds)]
- );
+ // Fetch roles (only if there are valid roleIds)
+ let roles: { documents: ProjectRole[] } = { documents: [] };
+ if (roleIds.length > 0) {
+ try {
+ roles = await databases.listDocuments(
+ DATABASE_ID,
+ PROJECT_ROLES_ID,
+ [Query.equal("$id", roleIds)]
+ );
+ } catch {
+ // Role fetch failed - continue with fallback
+ }
+ }
// Build role-to-team mapping and collect role permissions
const roleInfos: ProjectPermissionResult["roles"] = [];
@@ -204,6 +266,18 @@ export async function getProjectPermissionResult(
allPermissions.add(permission);
}
}
+
+ // Fallback: Also check the `role` field (PROJECT_OWNER, PROJECT_ADMIN, MEMBER, VIEWER)
+ // This handles cases where members have a direct role but no roleId
+ const memberRole = (membership as unknown as { role?: string }).role;
+ if (memberRole) {
+ const rolePerms = ROLE_PERMISSIONS[memberRole as keyof typeof ROLE_PERMISSIONS];
+ if (rolePerms) {
+ for (const perm of rolePerms) {
+ allPermissions.add(perm);
+ }
+ }
+ }
}
// ============================================================
@@ -259,8 +333,20 @@ export async function getProjectPermissionResult(
}
// Check if user is a project admin (has all permissions from default Project Admin role)
+ // Also check role field, roleName field, and role document name for OWNER/ADMIN
const projectAdminPermissions = DEFAULT_PROJECT_ROLES.PROJECT_ADMIN.permissions;
- const isProjectAdmin = projectAdminPermissions.every((p) => allPermissions.has(p));
+ const hasAdminPerms = projectAdminPermissions.every((p) => allPermissions.has(p));
+ const hasOwnerOrAdminRole = memberships.documents.some((m) => {
+ const mRole = (m as unknown as { role?: string }).role;
+ const mRoleName = (m as unknown as { roleName?: string }).roleName;
+ return mRole === "PROJECT_OWNER" || mRole === "PROJECT_ADMIN" ||
+ mRoleName === "OWNER" || mRoleName === "ADMIN";
+ });
+ // Also check from role documents
+ const hasOwnerOrAdminRoleDoc = roleInfos.some(
+ (r) => r.roleName === "OWNER" || r.roleName === "ADMIN"
+ );
+ const isProjectAdmin = hasAdminPerms || hasOwnerOrAdminRole || hasOwnerOrAdminRoleDoc;
return {
projectId,