diff --git a/backend/apps/mentorship/api/internal/mutations/module.py b/backend/apps/mentorship/api/internal/mutations/module.py
index e4496feab8..97e9ecc94f 100644
--- a/backend/apps/mentorship/api/internal/mutations/module.py
+++ b/backend/apps/mentorship/api/internal/mutations/module.py
@@ -47,6 +47,20 @@ def resolve_mentors_from_logins(logins: list[str]) -> set[Mentor]:
return mentors
+def _is_mentor_of_module(user, module) -> bool:
+ """Check if the given user is a mentor for the module.
+
+ Runs a fallback check against github_user if the mentor hasn't linked
+ their nest_user profile yet.
+ """
+ if Mentor.objects.filter(nest_user=user, modules=module).exists():
+ return True
+ return (
+ hasattr(user, "github_user")
+ and Mentor.objects.filter(github_user=user.github_user, modules=module).exists()
+ )
+
+
def _validate_module_dates(started_at, ended_at, program_started_at, program_ended_at) -> tuple:
"""Validate and normalize module start/end dates against program constraints."""
if started_at is None or ended_at is None:
@@ -144,7 +158,7 @@ def assign_issue_to_user(
if module is None:
raise ObjectDoesNotExist(MODULE_NOT_FOUND_MSG)
- if not Mentor.objects.filter(nest_user=user, modules=module).exists():
+ if not _is_mentor_of_module(user, module):
raise PermissionDenied(NOT_MENTOR_ASSIGN_MSG)
gh_user = GithubUser.objects.filter(login=user_login).first()
@@ -183,7 +197,7 @@ def unassign_issue_from_user(
if module is None:
raise ObjectDoesNotExist(MODULE_NOT_FOUND_MSG)
- if not Mentor.objects.filter(nest_user=user, modules=module).exists():
+ if not _is_mentor_of_module(user, module):
raise PermissionDenied(NOT_MENTOR_UNASSIGN_MSG)
gh_user = GithubUser.objects.filter(login=user_login).first()
@@ -224,7 +238,7 @@ def set_task_deadline(
if module is None:
raise ObjectDoesNotExist(MODULE_NOT_FOUND_MSG)
- if not Mentor.objects.filter(nest_user=user, modules=module).exists():
+ if not _is_mentor_of_module(user, module):
raise PermissionDenied(NOT_MENTOR_SET_DEADLINE_MSG)
issue = (
@@ -286,7 +300,7 @@ def clear_task_deadline(
if module is None:
raise ObjectDoesNotExist(MODULE_NOT_FOUND_MSG)
- if not Mentor.objects.filter(nest_user=user, modules=module).exists():
+ if not _is_mentor_of_module(user, module):
raise PermissionDenied(NOT_MENTOR_CLEAR_DEADLINE_MSG)
issue = (
@@ -319,7 +333,14 @@ def clear_task_deadline(
@strawberry.mutation(permission_classes=[IsAuthenticated])
@transaction.atomic
def update_module(self, info: strawberry.Info, input_data: UpdateModuleInput) -> ModuleNode:
- """Update an existing mentorship module."""
+ """Update an existing mentorship module.
+
+ User must either be:
+ - An admin of the program, or
+ - A mentor explicitly assigned to this module
+
+ Admins and module mentors can edit any field and manage mentor assignments.
+ """
user = info.context.request.user
try:
@@ -332,7 +353,7 @@ def update_module(self, info: strawberry.Info, input_data: UpdateModuleInput) ->
raise ObjectDoesNotExist(MODULE_NOT_FOUND_MSG) from e
is_admin = module.program.admins.filter(nest_user=user).exists()
- is_mentor = Mentor.objects.filter(nest_user=user, modules=module).exists()
+ is_mentor = _is_mentor_of_module(user, module)
if not (is_admin or is_mentor):
msg = "Only admins of the program or mentors of this module can edit modules."
raise PermissionDenied(msg)
@@ -391,3 +412,44 @@ def update_module(self, info: strawberry.Info, input_data: UpdateModuleInput) ->
module.program.save(update_fields=["experience_levels"])
return module
+
+ @strawberry.mutation(permission_classes=[IsAuthenticated])
+ @transaction.atomic
+ def delete_module(
+ self,
+ info: strawberry.Info,
+ program_key: str,
+ module_key: str,
+ ) -> str:
+ """Delete a mentorship module. User must be an admin of the program."""
+ user = info.context.request.user
+
+ try:
+ module = Module.objects.select_related("program").get(
+ key=module_key, program__key=program_key
+ )
+ except Module.DoesNotExist as e:
+ raise ObjectDoesNotExist(MODULE_NOT_FOUND_MSG) from e
+
+ if not module.program.admins.filter(nest_user=user).exists():
+ msg = "Only program admins can delete modules."
+ raise PermissionDenied(msg)
+
+ program = module.program
+ module_name = module.name
+
+ experience_level_to_remove = module.experience_level
+ if (
+ experience_level_to_remove in program.experience_levels
+ and not Module.objects.filter(
+ program=program, experience_level=experience_level_to_remove
+ )
+ .exclude(id=module.id)
+ .exists()
+ ):
+ program.experience_levels.remove(experience_level_to_remove)
+ program.save(update_fields=["experience_levels"])
+
+ module.delete()
+
+ return f"Module '{module_name}' has been deleted successfully."
diff --git a/frontend/__tests__/unit/components/CardDetailsPage.test.tsx b/frontend/__tests__/unit/components/CardDetailsPage.test.tsx
index 1293412174..a8d4d59734 100644
--- a/frontend/__tests__/unit/components/CardDetailsPage.test.tsx
+++ b/frontend/__tests__/unit/components/CardDetailsPage.test.tsx
@@ -445,6 +445,7 @@ jest.mock('components/EntityActions', () => ({
moduleKey,
status: _status,
setStatus: _setStatus,
+ isAdmin,
...props
}: {
type: string
@@ -452,9 +453,10 @@ jest.mock('components/EntityActions', () => ({
moduleKey?: string
status?: string
setStatus?: (status: string) => void
+ isAdmin?: boolean
[key: string]: unknown
}) => (
-
+
EntityActions: type={type}, programKey={programKey}, moduleKey={moduleKey}
),
diff --git a/frontend/__tests__/unit/components/EntityActions.test.tsx b/frontend/__tests__/unit/components/EntityActions.test.tsx
index b06e30f85f..8cb46bf144 100644
--- a/frontend/__tests__/unit/components/EntityActions.test.tsx
+++ b/frontend/__tests__/unit/components/EntityActions.test.tsx
@@ -1,3 +1,5 @@
+import { useMutation } from '@apollo/client/react'
+import { addToast } from '@heroui/toast'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { useRouter } from 'next/navigation'
import { ProgramStatusEnum } from 'types/__generated__/graphql'
@@ -9,12 +11,21 @@ jest.mock('next/navigation', () => ({
useRouter: jest.fn(),
}))
+jest.mock('@apollo/client/react', () => ({
+ useMutation: jest.fn(),
+}))
+
+jest.mock('@heroui/toast', () => ({
+ addToast: jest.fn(),
+}))
+
describe('EntityActions', () => {
beforeEach(() => {
jest.clearAllMocks()
;(useRouter as jest.Mock).mockReturnValue({
push: mockPush,
})
+ ;(useMutation as unknown as jest.Mock).mockReturnValue([jest.fn()])
})
describe('Program Actions - Create Module', () => {
@@ -82,7 +93,14 @@ describe('EntityActions', () => {
describe('Module Actions - View Issues', () => {
it('navigates to view issues page when View Issues is clicked with moduleKey', () => {
- render(
)
+ render(
+
+ )
const button = screen.getByRole('button', { name: /Module actions menu/ })
fireEvent.click(button)
@@ -95,7 +113,7 @@ describe('EntityActions', () => {
})
it('does not navigate when moduleKey is missing for view issues action', () => {
- render(
)
+ render(
)
const button = screen.getByRole('button', { name: /Module actions menu/ })
fireEvent.click(button)
@@ -106,7 +124,14 @@ describe('EntityActions', () => {
})
it('closes dropdown after clicking View Issues', () => {
- render(
)
+ render(
+
+ )
const button = screen.getByRole('button', { name: /Module actions menu/ })
fireEvent.click(button)
expect(button).toHaveAttribute('aria-expanded', 'true')
@@ -738,4 +763,266 @@ describe('EntityActions', () => {
expect(button).toHaveAttribute('aria-expanded', 'false')
})
})
+
+ describe('Module Actions - Delete Module', () => {
+ let mockDeleteMutation: jest.Mock
+
+ beforeEach(() => {
+ mockDeleteMutation = jest.fn()
+ ;(useMutation as unknown as jest.Mock).mockReturnValue([mockDeleteMutation])
+ })
+
+ it('opens delete modal when Delete is clicked', () => {
+ render(
+
+ )
+ const button = screen.getByRole('button', { name: /Module actions menu/ })
+ fireEvent.click(button)
+
+ const deleteButton = screen.getByText('Delete')
+ fireEvent.click(deleteButton)
+
+ expect(screen.getByText('Delete Module')).toBeInTheDocument()
+ expect(screen.getByText(/Are you sure you want to delete this module/)).toBeInTheDocument()
+ })
+
+ it('closes delete modal when Cancel is clicked', async () => {
+ render(
+
+ )
+ fireEvent.click(screen.getByRole('button', { name: /Module actions menu/ }))
+ fireEvent.click(screen.getByText('Delete'))
+
+ expect(screen.getByText('Delete Module')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: /Cancel/i }))
+
+ await waitFor(() => {
+ expect(screen.queryByText('Delete Module')).not.toBeInTheDocument()
+ })
+ })
+
+ it('closes delete modal when Modal close button is clicked (onClose)', async () => {
+ render(
+
+ )
+ fireEvent.click(screen.getByRole('button', { name: /Module actions menu/ }))
+ fireEvent.click(screen.getByText('Delete'))
+
+ expect(screen.getByText('Delete Module')).toBeInTheDocument()
+
+ const closeButton = screen.getByRole('button', { name: /Close/i })
+ fireEvent.click(closeButton)
+
+ await waitFor(() => {
+ expect(screen.queryByText('Delete Module')).not.toBeInTheDocument()
+ })
+ })
+
+ it('does not do anything if moduleKey is missing on submit', async () => {
+ render(
)
+ fireEvent.click(screen.getByRole('button', { name: /Module actions menu/ }))
+ fireEvent.click(screen.getByText('Delete'))
+
+ const confirmButton = screen.getByRole('button', { name: 'Delete' })
+ fireEvent.click(confirmButton)
+
+ expect(mockDeleteMutation).not.toHaveBeenCalled()
+ })
+
+ it('handles successful module deletion', async () => {
+ mockDeleteMutation.mockResolvedValueOnce({ data: { deleteModule: true } })
+
+ render(
+
+ )
+ fireEvent.click(screen.getByRole('button', { name: /Module actions menu/ }))
+ fireEvent.click(screen.getByText('Delete'))
+
+ const confirmButton = screen.getByRole('button', { name: 'Delete' })
+ fireEvent.click(confirmButton)
+
+ await waitFor(() => {
+ expect(mockDeleteMutation).toHaveBeenCalledWith({
+ variables: { programKey: 'test-program', moduleKey: 'test-module' },
+ update: expect.any(Function),
+ })
+ expect(addToast).toHaveBeenCalledWith({
+ title: 'Success',
+ description: 'Module has been deleted successfully.',
+ color: 'success',
+ })
+ expect(mockPush).toHaveBeenCalledWith('/my/mentorship/programs/test-program')
+ })
+ })
+
+ it('calls update cache function when delete is successful', async () => {
+ const mockCache = {
+ readQuery: jest.fn().mockReturnValue({
+ getProgramModules: [{ key: 'test-module' }, { key: 'other-module' }],
+ }),
+ writeQuery: jest.fn(),
+ }
+
+ mockDeleteMutation.mockImplementationOnce(({ update }) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ if (update) update(mockCache as any)
+ return Promise.resolve({ data: { deleteModule: true } })
+ })
+
+ render(
+
+ )
+ fireEvent.click(screen.getByRole('button', { name: /Module actions menu/ }))
+ fireEvent.click(screen.getByText('Delete'))
+
+ fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
+
+ await waitFor(() => {
+ expect(mockCache.readQuery).toHaveBeenCalled()
+ expect(mockCache.writeQuery).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: {
+ getProgramModules: [{ key: 'other-module' }],
+ },
+ })
+ )
+ })
+ })
+
+ it('handles mutation structural failure', async () => {
+ mockDeleteMutation.mockResolvedValueOnce({ data: { deleteModule: false } })
+
+ render(
+
+ )
+ fireEvent.click(screen.getByRole('button', { name: /Module actions menu/ }))
+ fireEvent.click(screen.getByText('Delete'))
+
+ fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
+
+ await waitFor(() => {
+ expect(addToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: 'Error',
+ color: 'danger',
+ description: 'Failed to delete module. Please try again.',
+ })
+ )
+ })
+ })
+
+ it('handles Permission Denied error from server', async () => {
+ mockDeleteMutation.mockRejectedValueOnce(
+ new Error('Permission denied: You do not have permission')
+ )
+
+ render(
+
+ )
+ fireEvent.click(screen.getByRole('button', { name: /Module actions menu/ }))
+ fireEvent.click(screen.getByText('Delete'))
+
+ fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
+
+ await waitFor(() => {
+ expect(addToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: 'Error',
+ color: 'danger',
+ description:
+ 'You do not have permission to delete this module. Only program admins can delete modules.',
+ })
+ )
+ })
+ })
+
+ it('handles Unauthorized error from server', async () => {
+ mockDeleteMutation.mockRejectedValueOnce(new Error('Unauthorized request'))
+
+ render(
+
+ )
+ fireEvent.click(screen.getByRole('button', { name: /Module actions menu/ }))
+ fireEvent.click(screen.getByText('Delete'))
+
+ fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
+
+ await waitFor(() => {
+ expect(addToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: 'Error',
+ color: 'danger',
+ description: 'Unauthorized: You must be a program admin to delete modules.',
+ })
+ )
+ })
+ })
+
+ it('handles a generic network error from server', async () => {
+ mockDeleteMutation.mockRejectedValueOnce(new Error('Network disconnected'))
+
+ render(
+
+ )
+ fireEvent.click(screen.getByRole('button', { name: /Module actions menu/ }))
+ fireEvent.click(screen.getByText('Delete'))
+
+ fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
+
+ await waitFor(() => {
+ expect(addToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: 'Error',
+ color: 'danger',
+ description: 'Failed to delete module. Please try again.',
+ })
+ )
+ })
+ })
+ })
})
diff --git a/frontend/__tests__/unit/pages/CreateModule.test.tsx b/frontend/__tests__/unit/pages/CreateModule.test.tsx
index 405210b68e..e08a8ee16f 100644
--- a/frontend/__tests__/unit/pages/CreateModule.test.tsx
+++ b/frontend/__tests__/unit/pages/CreateModule.test.tsx
@@ -359,5 +359,5 @@ describe('CreateModulePage', () => {
})
)
})
- })
+ }, 10000)
})
diff --git a/frontend/__tests__/unit/pages/EditModule.test.tsx b/frontend/__tests__/unit/pages/EditModule.test.tsx
index 0af4eb86d8..adb2893b7c 100644
--- a/frontend/__tests__/unit/pages/EditModule.test.tsx
+++ b/frontend/__tests__/unit/pages/EditModule.test.tsx
@@ -150,7 +150,7 @@ describe('EditModulePage', () => {
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith({
title: 'Access Denied',
- description: 'Only program admins can edit modules.',
+ description: 'Only program admins and module mentors can edit this module.',
color: 'danger',
variant: 'solid',
timeout: 4000,
diff --git a/frontend/jest.setup.ts b/frontend/jest.setup.ts
index cd29a0b6fa..d8b8217118 100644
--- a/frontend/jest.setup.ts
+++ b/frontend/jest.setup.ts
@@ -165,4 +165,17 @@ jest.mock('ics', () => {
}
})
+jest.mock('@apollo/client/react', () => {
+ const actual = jest.requireActual('@apollo/client/react')
+ const mockUseMutation = jest.fn(() => [
+ jest.fn().mockResolvedValue({ data: {} }),
+ { data: null, loading: false, error: null, called: false },
+ ])
+
+ return {
+ ...actual,
+ useMutation: mockUseMutation,
+ }
+})
+
expect.extend(toHaveNoViolations)
diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/edit/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/edit/page.tsx
index 8b4543b9f6..4767957ce4 100644
--- a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/edit/page.tsx
+++ b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/edit/page.tsx
@@ -5,7 +5,7 @@ import { addToast } from '@heroui/toast'
import { useParams, useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import React, { useEffect, useState } from 'react'
-import { ErrorDisplay, handleAppError } from 'app/global-error'
+import { ErrorDisplay } from 'app/global-error'
import { ExperienceLevelEnum } from 'types/__generated__/graphql'
import type { UpdateModuleInput } from 'types/__generated__/graphql'
import { UpdateModuleDocument } from 'types/__generated__/moduleMutations.generated'
@@ -21,7 +21,10 @@ import ModuleForm from 'components/ModuleForm'
const EditModulePage = () => {
const { programKey, moduleKey } = useParams<{ programKey: string; moduleKey: string }>()
const router = useRouter()
- const { data: sessionData, status: sessionStatus } = useSession()
+ const { data: sessionData, status: sessionStatus } = useSession() as {
+ data: ExtendedSession | null
+ status: string
+ }
const [formData, setFormData] = useState
(null)
const [accessStatus, setAccessStatus] = useState<'checking' | 'allowed' | 'denied'>('checking')
@@ -53,18 +56,22 @@ const EditModulePage = () => {
return
}
- const currentUserLogin = (sessionData as ExtendedSession)?.user?.login
+ const currentUserLogin = sessionData?.user?.login
const isAdmin = data.getProgram.admins?.some(
(admin: { login: string }) => admin.login === currentUserLogin
)
- if (isAdmin) {
+ const isMentor = data.getModule.mentors?.some(
+ (mentor: { login: string }) => mentor.login === currentUserLogin
+ )
+
+ if (isAdmin || isMentor) {
setAccessStatus('allowed')
} else {
setAccessStatus('denied')
addToast({
title: 'Access Denied',
- description: 'Only program admins can edit modules.',
+ description: 'Only program admins and module mentors can edit this module.',
color: 'danger',
variant: 'solid',
timeout: 4000,
@@ -97,6 +104,11 @@ const EditModulePage = () => {
if (!formData) return
try {
+ const currentUserLogin = sessionData?.user?.login
+ const isAdmin = data?.getProgram?.admins?.some(
+ (admin: { login: string }) => admin.login === currentUserLogin
+ )
+
const input: UpdateModuleInput = {
description: formData.description,
domains: parseCommaSeparated(formData.domains),
@@ -104,7 +116,6 @@ const EditModulePage = () => {
experienceLevel: formData.experienceLevel as ExperienceLevelEnum,
key: moduleKey,
labels: parseCommaSeparated(formData.labels),
- mentorLogins: parseCommaSeparated(formData.mentorLogins),
name: formData.name,
programKey: programKey,
projectId: formData.projectId,
@@ -113,6 +124,10 @@ const EditModulePage = () => {
tags: parseCommaSeparated(formData.tags),
}
+ if (isAdmin) {
+ input.mentorLogins = parseCommaSeparated(formData.mentorLogins)
+ }
+
const result = await updateModule({
awaitRefetchQueries: true,
refetchQueries: [{ query: GetProgramAndModulesDocument, variables: { programKey } }],
@@ -129,7 +144,22 @@ const EditModulePage = () => {
})
router.push(`/my/mentorship/programs/${programKey}/modules/${updatedModuleKey}`)
} catch (err) {
- handleAppError(err)
+ let errorMessage = 'Failed to update module. Please try again.'
+
+ if (err instanceof Error) {
+ if (err.message.includes('Permission') || err.message.includes('not have permission')) {
+ errorMessage =
+ 'You do not have permission to edit this module. Only program admins and assigned mentors can edit modules.'
+ }
+ }
+
+ addToast({
+ title: 'Error',
+ description: errorMessage,
+ color: 'danger',
+ variant: 'solid',
+ timeout: 4000,
+ })
}
}
diff --git a/frontend/src/components/CardDetailsPage.tsx b/frontend/src/components/CardDetailsPage.tsx
index 4ecd666e49..72e9ae4587 100644
--- a/frontend/src/components/CardDetailsPage.tsx
+++ b/frontend/src/components/CardDetailsPage.tsx
@@ -153,11 +153,22 @@ const DetailsCard = ({
/>
)}
{type === 'module' &&
- accessLevel === 'admin' &&
- programKey &&
- admins?.some((admin) => admin.login === session?.user?.login) && (
-
- )}
+ (() => {
+ if (!programKey || !entityKey) return null
+ const currentUserLogin = session?.user?.login
+ const isAdmin =
+ accessLevel === 'admin' &&
+ admins?.some((admin) => admin.login === currentUserLogin)
+ const isMentor = mentors?.some((mentor) => mentor.login === currentUserLogin)
+ return isAdmin || isMentor ? (
+
+ ) : null
+ })()}
{!isActive && }
{isArchived && type === 'repository' && }
{IS_PROJECT_HEALTH_ENABLED &&
diff --git a/frontend/src/components/EntityActions.tsx b/frontend/src/components/EntityActions.tsx
index 329b15a378..ea6357a235 100644
--- a/frontend/src/components/EntityActions.tsx
+++ b/frontend/src/components/EntityActions.tsx
@@ -1,10 +1,26 @@
'use client'
+import { gql } from '@apollo/client'
+import { useMutation } from '@apollo/client/react'
+import { Button } from '@heroui/button'
+import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from '@heroui/modal'
+import { addToast } from '@heroui/toast'
import { useRouter } from 'next/navigation'
import type React from 'react'
import { useState, useRef, useEffect } from 'react'
import { FaEllipsisV } from 'react-icons/fa'
import { ProgramStatusEnum } from 'types/__generated__/graphql'
+import { GetProgramAndModulesDocument } from 'types/__generated__/programsQueries.generated'
+
+const DELETE_MODULE_MUTATION = gql`
+ mutation DeleteModule($programKey: String!, $moduleKey: String!) {
+ deleteModule(programKey: $programKey, moduleKey: $moduleKey)
+ }
+`
+
+type DeleteModuleResponse = {
+ deleteModule: boolean
+}
interface EntityActionsProps {
type: 'program' | 'module'
@@ -12,6 +28,7 @@ interface EntityActionsProps {
moduleKey?: string
status?: string
setStatus?: (newStatus: ProgramStatusEnum) => void | Promise
+ isAdmin?: boolean
}
const EntityActions: React.FC = ({
@@ -20,14 +37,19 @@ const EntityActions: React.FC = ({
moduleKey,
status,
setStatus,
+ isAdmin = false,
}) => {
const router = useRouter()
const [dropdownOpen, setDropdownOpen] = useState(false)
+ const [deleteModalOpen, setDeleteModalOpen] = useState(false)
+ const [isDeleting, setIsDeleting] = useState(false)
const [focusIndex, setFocusIndex] = useState(-1)
const dropdownRef = useRef(null)
const triggerButtonRef = useRef(null)
const menuItemsRef = useRef<(HTMLButtonElement | null)[]>([])
+ const [deleteModule] = useMutation(DELETE_MODULE_MUTATION)
+
const handleAction = (actionKey: string) => {
switch (actionKey) {
case 'edit_program':
@@ -46,6 +68,9 @@ const EntityActions: React.FC = ({
router.push(`/my/mentorship/programs/${programKey}/modules/${moduleKey}/issues`)
}
break
+ case 'delete_module':
+ setDeleteModalOpen(true)
+ break
case 'publish':
setStatus?.(ProgramStatusEnum.Published)
break
@@ -59,6 +84,70 @@ const EntityActions: React.FC = ({
setDropdownOpen(false)
}
+ const handleDeleteConfirm = async () => {
+ if (!moduleKey) return
+
+ setIsDeleting(true)
+
+ try {
+ const result = await deleteModule({
+ variables: { programKey, moduleKey },
+
+ update(cache) {
+ const existing = cache.readQuery({
+ query: GetProgramAndModulesDocument,
+ variables: { programKey },
+ })
+
+ if (existing?.getProgramModules) {
+ cache.writeQuery({
+ query: GetProgramAndModulesDocument,
+ variables: { programKey },
+ data: {
+ ...existing,
+ getProgramModules: existing.getProgramModules.filter(
+ (module) => module.key !== moduleKey
+ ),
+ },
+ })
+ }
+ },
+ })
+
+ if (!result?.data || typeof result.data !== 'object' || !result.data.deleteModule) {
+ throw new Error('Delete mutation failed on server')
+ }
+
+ addToast({
+ title: 'Success',
+ description: 'Module has been deleted successfully.',
+ color: 'success',
+ })
+
+ setDeleteModalOpen(false)
+ router.push(`/my/mentorship/programs/${programKey}`)
+ } catch (error) {
+ let description = 'Failed to delete module. Please try again.'
+
+ if (error instanceof Error) {
+ if (error.message.includes('Permission') || error.message.includes('not have permission')) {
+ description =
+ 'You do not have permission to delete this module. Only program admins can delete modules.'
+ } else if (error.message.includes('Unauthorized')) {
+ description = 'Unauthorized: You must be a program admin to delete modules.'
+ }
+ }
+
+ addToast({
+ title: 'Error',
+ description,
+ color: 'danger',
+ })
+ } finally {
+ setIsDeleting(false)
+ }
+ }
+
const options =
type === 'program'
? [
@@ -74,7 +163,10 @@ const EntityActions: React.FC = ({
]
: [
{ key: 'edit_module', label: 'Edit' },
- { key: 'view_issues', label: 'View Issues' },
+ ...(isAdmin ? [{ key: 'view_issues', label: 'View Issues' }] : []),
+ ...(isAdmin
+ ? [{ key: 'delete_module', label: 'Delete', className: 'text-red-500' }]
+ : []),
]
useEffect(() => {
@@ -138,52 +230,86 @@ const EntityActions: React.FC = ({
}
return (
-
-
- {dropdownOpen && (
-
+
+
+
+ {type === 'module' && (
+
setDeleteModalOpen(false)}>
+
+ Delete Module
+
+ Are you sure you want to delete this module? This action cannot be undone.
+
+
+ setDeleteModalOpen(false)}
+ disabled={isDeleting}
+ >
+ Cancel
+
+
- {option.label}
-
- )
- })}
-
+ Delete
+
+
+
+
)}
-
+ >
)
}
diff --git a/frontend/src/components/SingleModuleCard.tsx b/frontend/src/components/SingleModuleCard.tsx
index 5c84628384..1b8eadaa9f 100644
--- a/frontend/src/components/SingleModuleCard.tsx
+++ b/frontend/src/components/SingleModuleCard.tsx
@@ -9,7 +9,7 @@ import { useSession } from 'next-auth/react'
import React, { useState } from 'react'
import { FaFolderOpen } from 'react-icons/fa'
import { HiUserGroup } from 'react-icons/hi'
-import { ExtendedSession } from 'types/auth'
+import type { ExtendedSession } from 'types/auth'
import type { Contributor } from 'types/contributor'
import type { Module } from 'types/mentorship'
import { formatDate } from 'utils/dateFormatter'
@@ -28,14 +28,17 @@ interface SingleModuleCardProps {
}
const SingleModuleCard: React.FC = ({ module, accessLevel, admins }) => {
- const { data } = useSession()
+ const { data: sessionData } = useSession() as { data: ExtendedSession | null }
const pathname = usePathname()
const [showAllMentors, setShowAllMentors] = useState(false)
const [showAllMentees, setShowAllMentees] = useState(false)
+ const currentUserLogin = sessionData?.user?.login
+
const isAdmin =
- accessLevel === 'admin' &&
- admins?.some((admin) => admin.login === (data as ExtendedSession)?.user?.login)
+ accessLevel === 'admin' && admins?.some((admin) => admin.login === currentUserLogin)
+
+ const isMentor = module.mentors?.some((mentor) => mentor.login === currentUserLogin)
// Extract programKey from pathname (e.g., /my/mentorship/programs/[programKey])
const programKey = pathname?.split('/programs/')[1]?.split('/')[0] || ''
@@ -120,7 +123,14 @@ const SingleModuleCard: React.FC = ({ module, accessLevel
- {isAdmin &&