Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,10 @@ <h1 class="title is-2 has-text-weight-light">
></a>
<a
class="button is-light is-small is-circle"
(click)="toggleDeleteModal(item)"
[class.is-disabled]="entityManifest.slug === 'admins' && item['id'] === currentAdminId"
[attr.aria-disabled]="entityManifest.slug === 'admins' && item['id'] === currentAdminId ? true : null"
[attr.title]="entityManifest.slug === 'admins' && item['id'] === currentAdminId ? 'You cannot delete your own admin account' : null"
(click)="entityManifest.slug === 'admins' && item['id'] === currentAdminId ? null : toggleDeleteModal(item)"
><i class="icon icon-trash-2"></i
></a>
</div>
Expand Down Expand Up @@ -198,10 +201,18 @@ <h1 class="title is-2 has-text-weight-light">
<button class="button is-white" (click)="toggleDeleteModal()">
Cancel
</button>
<button class="button is-danger" (click)="delete(itemToDelete['id'])">
<button
class="button is-danger"
[disabled]="entityManifest.slug === 'admins' && itemToDelete && itemToDelete['id'] === currentAdminId"
[attr.title]="entityManifest.slug === 'admins' && itemToDelete && itemToDelete['id'] === currentAdminId ? 'You cannot delete your own admin account' : null"
(click)="delete(itemToDelete['id'])"
>
Delete
</button>
</div>
<p class="help is-danger mt-2" *ngIf="entityManifest.slug === 'admins' && itemToDelete && itemToDelete['id'] === currentAdminId">
You cannot delete your own admin account.
</p>
<button
class="modal-close is-large"
aria-label="close"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { ManifestService } from '../../../shared/services/manifest.service'
import { CrudService } from '../../services/crud.service'
import { MetaService } from '../../../shared/services/meta.service'
import { CapitalizeFirstLetterPipe } from '../../../shared/pipes/capitalize-first-letter.pipe'
import { AuthService } from '../../../auth/auth.service'

@Component({
selector: 'app-list',
Expand All @@ -28,6 +29,9 @@ export class ListComponent implements OnInit {
entityManifest: EntityManifest
properties: PropertyManifest[]

// Current admin ID used to prevent self-deletion in admin listing
currentAdminId: string | null = null

queryParams: Params
PropType = PropType

Expand All @@ -38,10 +42,17 @@ export class ListComponent implements OnInit {
private activatedRoute: ActivatedRoute,
private metaService: MetaService,
private flashMessageService: FlashMessageService,
private renderer: Renderer2
private renderer: Renderer2,
private authService: AuthService
) {}

ngOnInit(): void {
// Ensure current admin is loaded and keep ID in sync
this.authService.currentUser$.subscribe((admin) => {
this.currentAdminId = admin?.id || null
})
this.authService.loadCurrentUser().subscribe()

combineLatest([
this.activatedRoute.queryParams,
this.activatedRoute.params
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import {
Query,
Req,
UseGuards,
UseInterceptors
UseInterceptors,
HttpException,
HttpStatus
} from '@nestjs/common'

import { BaseEntity, Paginator, SelectOption } from '@repo/types'
Expand All @@ -22,7 +24,7 @@ import { PolicyGuard } from '../../policy/policy.guard'
import { Rule } from '../../policy/decorators/rule.decorator'
import { IsCollectionGuard } from '../guards/is-collection.guard'
import { HookInterceptor } from '../../hook/hook.interceptor'
import { COLLECTIONS_PATH } from '../../constants'
import { ADMIN_ENTITY_MANIFEST, COLLECTIONS_PATH } from '../../constants'
import { MiddlewareInterceptor } from '../../middleware/middleware.interceptor'
import { IsAdminGuard } from '../../auth/guards/is-admin.guard'

Expand Down Expand Up @@ -126,10 +128,22 @@ export class CollectionController {

@Delete(':entity/:id')
@Rule('delete')
delete(
async delete(
@Param('entity') entity: string,
@Param('id', ParseUUIDPipe) id: string
@Param('id', ParseUUIDPipe) id: string,
@Req() req: Request
): Promise<BaseEntity> {
// Prevent an admin from deleting their own account
if (entity === ADMIN_ENTITY_MANIFEST.slug) {
const requestUser = await this.authService.getUserFromRequest(req)
if (requestUser?.user?.id === id) {
throw new HttpException(
'You cannot delete your own admin account.',
HttpStatus.FORBIDDEN
)
}
}

return this.crudService.delete(entity, id)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@ import { HookService } from '../../hook/hook.service'
import { EventService } from '../../event/event.service'
import { HandlerService } from '../../handler/handler.service'
import { EntityService } from '../../entity/services/entity.service'
import { ADMIN_ENTITY_MANIFEST } from '../../constants'
import { HttpException } from '@nestjs/common'

describe('CollectionController', () => {
let controller: CollectionController
let crudService: CrudService
let authService: AuthService

const randomUuid: string = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'

Expand All @@ -24,7 +27,8 @@ describe('CollectionController', () => {
{
provide: AuthService,
useValue: {
isReqUserAdmin: jest.fn(() => Promise.resolve(false))
isReqUserAdmin: jest.fn(() => Promise.resolve(false)),
getUserFromRequest: jest.fn()
}
},
{
Expand Down Expand Up @@ -91,6 +95,7 @@ describe('CollectionController', () => {

controller = module.get<CollectionController>(CollectionController)
crudService = module.get<CrudService>(CrudService)
authService = module.get<AuthService>(AuthService)
})

it('should be defined', () => {
Expand Down Expand Up @@ -180,8 +185,38 @@ describe('CollectionController', () => {
it('should call crudService.delete', async () => {
const entitySlug = 'cats'

await controller.delete(entitySlug, randomUuid)
await controller.delete(entitySlug, randomUuid, {} as any)

expect(crudService.delete).toHaveBeenCalledWith(entitySlug, randomUuid)
})

it("should prevent an admin from deleting their own account", async () => {
const adminSlug = ADMIN_ENTITY_MANIFEST.slug
const req = {} as any

;(authService.getUserFromRequest as jest.Mock).mockResolvedValue({
user: { id: randomUuid },
entitySlug: adminSlug
})

await expect(
controller.delete(adminSlug, randomUuid, req)
).rejects.toThrow(HttpException)

expect(crudService.delete).not.toHaveBeenCalled()
})

it('should allow deleting another admin account', async () => {
const adminSlug = ADMIN_ENTITY_MANIFEST.slug
const req = {} as any

;(authService.getUserFromRequest as jest.Mock).mockResolvedValue({
user: { id: 'different-id' },
entitySlug: adminSlug
})

await controller.delete(adminSlug, randomUuid, req)

expect(crudService.delete).toHaveBeenCalledWith(adminSlug, randomUuid)
})
})