Reusable Confirm-Destructive Modal

Author: Patrick Miguel M. Babala
Developers & Reviewers: Clyde Ador, Patrick Babala, and Christian Denzon
Creation Date: September 29, 2026
Status: Published
References: Issue #777, docs/FEATURE_AUDIT_MAY2026.md:127,285,508, PR #780, ad657492, b65e7991, 13373adb, 1210c258, 07530207, supabase/migrations/20260414_create_audit_logs_table.sql, docs/PR_DESCRIPTION_reusable-confirm-destructive-modal.md, docs/PR_Iterations_reusable-confirm-destructive-modal.md


Introduction & Goals

Problem Summary

Before Issue #777, every destructive action in WyzQuests used a one-off confirmation dialog. Destructive flows were inconsistent: some had no confirmation at all (gamification badge/trigger deletes, notification deletes, activity deletes ran immediately), some used a soft "Are you sure?" button, and the four high-risk flows used bespoke type-to-confirm inputs with divergent tokens. The audit (docs/FEATURE_AUDIT_MAY2026.md:127) flagged this as "Inconsistent" and recommended a single reusable <ConfirmDestructiveModal>.

Goals & Non-Goals

Goals:

  • One controlled component that enforces a typed-confirmation challenge; the destructive button stays disabled until the exact token is typed (components/shared/confirm-destructive-modal.tsx:19-38, 76-83).

  • Server-side re-validation of the token on the four named high-risk endpoints (Issue #777 AC3).

  • Replace every ad-hoc confirm dialog across creator hub, content library, quest editor, learner, forum, reviewer, admin, and agency with the shared component; delete the orphans.

  • Preserve per-flow requirements (member-reassignment select, bulk item lists, case-insensitive title tokens) without branching the component.

Non-Goals:

  • Server-side token enforcement for the 13+ non-named destructive flows (explicitly scoped out; follow-up issue — docs/PR_DESCRIPTION_reusable-confirm-destructive-modal.md:101).

  • Admin re-authentication (W0.5) — unchanged; the modal chains before the existing ReAuthModal for admin user delete (app/admin/manage-users/page.tsx:319-341).

  • Soft delete / archive / restore flows — those keep their own non-destructive dialogs (e.g. components/creator/background-assets/DeleteAssetModal, "Move to Trash").

  • Making the modal fetch or own request lifecycle — the parent owns the request and isSubmitting (components/shared/confirm-destructive-modal.tsx:28-31).

Glossary

Term

Definition

Token

The literal text the user must type (confirmToken), e.g. DELETE, RESET, or a quest title.

matchMode

"exact" (default, case-sensitive) or "case-insensitive" (entity-title tokens) — components/shared/confirm-destructive-modal.tsx:17,32-33.

extraContent

Optional slot rendered above the token input (item lists, reassign selects, warnings) — components/shared/confirm-destructive-modal.tsx:34-35,115.

AC1/AC2/AC3

Acceptance criteria from Issue #777: accepts title/body/token/onConfirm; disabled until exact match; consuming endpoints re-validate server-side — see component docstring :45-48.

W0.5

Pre-existing admin re-auth token flow (5-minute token) — supabase/migrations/20260526_create_admin_reauth_tokens.sql.

W1.4

API response-standardization envelope (ApiResponseHelper) — lib/api/response.ts:52-57.


High-Level Architecture

System Diagram

+----------------------------------------------------------------------------------------------------+
| Client components (parent owns state + fetch) |
| |
| [Creator hub] [Content library] [Quest editor] |
| MyContent, ContentCard, FolderCard, canvas linear/exploration, |
| TrashContent, FolderTreeView, form-editor, enrollment, |
| ContentListRow, FolderWrapper, SettingsDropdown, |
| archive FolderPermissionsModal, ArchivedNodesModal |
| ContentLibraryPage |
| |
| [Learner] [Forum / reviewer] [Admin / agency] |
| QuestPlayer, PostDetail, manage-users, |
| ActiveQuestsList CommentSection, agencies, agency/team, |
| CommentThread, gamification |
| GuestInviteModal |
+--------------------------------------------------+-------------------------------------------------+
|
v
+----------------------------------------------------------------------------------------------------+
| ConfirmDestructiveModal |
| components/shared/confirm-destructive-modal.tsx |
| • controlled open/onOpenChange |
| • typed-token gate (AC2) |
| • extraContent slot, isSubmitting |
+--------------------------------------------------+-------------------------------------------------+
|
| onConfirm(confirmText)
v
+----------------------------------------------------------------------------------------------------+
| Parent fetch (token attached) |
+--------------------------------------------------+-------------------------------------------------+
| | | |
v v v v
+-----------------------+ +-----------------------+ +--------------------+ +-------------------------+
| DELETE /api/creator/ | | DELETE /api/creator/ | | POST /api/learner/ | | DELETE /api/admin/ |
| permanent-delete | | purge-assets | | reset-quest | | delete-user |
+-----------------------+ +-----------------------+ +--------------------+ +-------------------------+
| | | |
| confirm_text === 'DELETE' | z.literal('DELETE') | z.literal('RESET') | z.literal('DELETE')
| (permanentDeleteSchema) | (purgeAssetsSchema) | | + reauthToken
v v v v
+-----------------------+ +-----------------------+ +--------------------+ +-------------------------+
| Supabase Postgres | | Supabase Postgres | | Supabase Postgres | | Supabase Postgres / |
| quests / adventures | | asset_metadata | | quest_enrollments | | Supabase Auth |
| | | | | | app_users |
+-----------------------+ +-----------------------+ +--------------------+ +-------------------------+
| | |
+---------------+---------------+ |
| v
v +-----------------+
+-------------------+ | audit_logs |
| Storage: | | USER_DELETE |
| public-assets | +-----------------+
+-------------------+
^
| (on failure)
+-------------------------+
| audit_logs |
| PERMANENT_DELETE_FAILED |
+-------------------------+
 
+----------------------------------------------------------------------------------------------------+
| Shared helper (Learner flow): |
| ActiveQuestsList / QuestPlayer ---> resetQuestProgress(questId): Promise<boolean> ---> POST reset |
| (components/learner/resetQuest.ts:13) |
+----------------------------------------------------------------------------------------------------+

Technologies Used

Concern

Technology

UI Runtime

Next.js 16 App Router, React 19, TypeScript strict ("use client" at components/shared/confirm-destructive-modal.tsx:1)

Component Primitives

Shadcn/UI Dialog, Button, Input, Label; lucide-react icons (AlertTriangle, Loader2) (:4-15)

Styling

Tailwind v4 utility classes; brand tokens text-brand-navy, bg-destructive/10 (:103,106)

Validation

Zod v4 (z.literal, z.string().min) (shared/schemas/contentManagementSchema.ts:56-98; app/api/learner/reset-quest/route.ts:7-12)

Data & Storage

Supabase Postgres + service-role client; Supabase Storage public-assets (lib/supabase; app/api/creator/(content)/permanent-delete/route.ts:12-14)

API Contract

ApiResponseHelper W1.4 envelope (lib/api/response.ts:32-50,58-105)

Auth & Authorization

Supabase Auth → internal app_users.id; agency ownership checks (lib/auth/agencyOwnership.ts:674; lib/auth/authenticate.ts:371,569)

Testing

Playwright e2e (@playwright/test) (tests/e2e/creator/permanent-delete.spec.ts:1)


Detailed Design & Implementation

Data Model / Schema

The feature adds no new tables or columns. It relies on pre-existing tables for audit and deletion. Where migrations conflict, the latest wins:

  • 20260414_create_audit_logs_table.sql: Creates audit_logs, indexes, RLS. Partially superseded by 20260526_create_admin_reauth_tokens.sql (re-declares idempotently) and RLS superseded by 20260612_update_rls_policies_multi_role.sql.

  • 20260526_create_admin_reauth_tokens.sql: Re-declares audit_logs idempotently and adds admin_reauth_tokens. Supersedes 20260414 for RLS-policy creation mechanism (uses DO $$ ... EXCEPTION); latest audit_logs shape wins.

  • 20260612_update_rls_policies_multi_role.sql:188-196: Replaces "Admins can view audit logs" with a multi-role EXISTS check. Latest RLS policy wins over 20260414 and 20260526.

  • 20260824_fix_not_null_set_null_user_fks.sql:18-30: Drops NOT NULL from audit_logs.user_id. Latest definition; required for ON DELETE SET NULL to work when deleting a user.

  • 20260504_create_quest_folder.sql:24: quests.quest_folder_id ... ON DELETE SET NULL. Governs folder-delete copy semantics (items move to root, are not deleted).

+-----------------------+ +-----------------------+
| app_users | | audit_logs |
|-----------------------| |-----------------------|
| id (PK) | 1 * | id (PK) |
| ... |--------------| user_id (FK, nullable)|
+-----------------------+ | action |
| 1 | entity_type |
| | entity_id |
| | details |
| | created_at |
| +-----------------------+
|
| 1
+-------------------------------+-----------------------+
| | |
| * | * | *
v v v
+-----------------------+ +-----------------------+ +-----------------------+
| quests | | adventures | | asset_metadata |
|-----------------------| |-----------------------| |-----------------------|
| id (PK) | | id (PK) | | id (PK) |
| title | | title | | creator_id (FK) |
| publishing_status | | publishing_status | | file_url |
| creator_id (FK) | | creator_id (FK) | | file_path |
| quest_folder_id (FK) | | adventure_folder_id FK| | asset_type |
+-----------------------+ +-----------------------+ | file_name |
^ +-----------------------+
| *
|
| 1
+-----------------------+ +-----------------------+
| quest_folders | | quest_enrollments |
|-----------------------| |-----------------------|
| id (PK) | | id (PK) |
| creator_id (FK) | | quest_id (FK) |
| parent_folder_id (FK) | | learner_id (FK) <-----+ (app_users.id)
+-----------------------+ | progress |
+-----------------------+
  • Composite keys: None on audit_logs. quest_milestone_earnings uses a composite unique constraint on (enrollment_id, milestone).

  • Foreign keys:

    • audit_logs.user_id references app_users(id) (ON DELETE SET NULL).

    • quests.creator_id references app_users(id).

    • quests.quest_folder_id references quest_folders(id) (ON DELETE SET NULL).

    • adventures.creator_id references app_users(id).

    • asset_metadata.creator_id references app_users(id).

    • quest_enrollments.quest_id references quests(id).

    • quest_enrollments.learner_id references app_users(id).

    • quest_folders.creator_id references app_users(id).

    • quest_folders.parent_folder_id references quest_folders(id).

  • ON DELETE CASCADE: Used by child entities under quests and adventures; folder deletion relies on ON DELETE SET NULL on quests.quest_folder_id.

  • Triggers: None added by this feature.

  • RLS helper functions: Latest RLS policy on audit_logs uses multi-role EXISTS check across app_users where id = auth.uid() and role is ADMIN.

Table: audit_logs

Column

Type

Constraints

Notes

id

UUID

PK, DEFAULT gen_random_uuid()

Generated record identifier

user_id

UUID

FK → app_users(id) ON DELETE SET NULL; nullable (after 20260824)

Internal app_users.id, not Clerk ID

action

VARCHAR(100)

NOT NULL

USER_DELETE, REAUTH_TOKEN_INVALID, PERMANENT_DELETE_FAILED

entity_type

VARCHAR(50)

Nullable

e.g. user, quests, adventures

entity_id

UUID

Nullable

For bulk delete this is omitted (app/api/creator/(content)/permanent-delete/route.ts:144)

details

JSONB

Nullable

e.g. { reason, content_ids } (:141)

ip_address

VARCHAR(45)

Nullable

Not written by this feature

user_agent

TEXT

Nullable

Not written by this feature

created_at

TIMESTAMPTZ

DEFAULT NOW()

Routers also set created_at explicitly

Indexes on audit_logs:

  • idx_audit_logs_user_id: audit_logs(user_id)

  • idx_audit_logs_action: audit_logs(action)

  • idx_audit_logs_created_at: audit_logs(created_at DESC)

  • idx_audit_logs_entity: audit_logs(entity_type, entity_id)

RLS policies on audit_logs:

  • "Admins can view audit logs": SELECT using EXISTS (SELECT 1 FROM app_users WHERE id = auth.uid() AND role = 'ADMIN') (latest multi-role variant in 20260612_update_rls_policies_multi_role.sql:188-196).

  • "System can insert audit logs": INSERT WITH CHECK (true) (20260414_create_audit_logs_table.sql:35-37).

API Specification

All four endpoints use the W1.4 envelope (lib/api/response.ts:32-50): success { success: true, message?, data, error: null }, error { success: false, message, data: null, error: { code, message, details? } }.

Method & Path

Auth

Purpose

Body / Query

DELETE /api/creator/permanent-delete

creator / agency / admin (getCreatorAuthContext → verifyCreatorAccess) + per-item ownership/folder-admin check

Permanently delete quests or adventures from trash

Body: { "content_type": "quests" | "adventures", "content_ids"?: string[], "content_id"?: string, "confirm_text": "DELETE" }

DELETE /api/creator/purge-assets

creator / agency / admin (getCreatorAuthContext) + per-asset ownership check

Permanently delete assets from storage and database

Body: { "asset_ids"?: string[], "asset_id"?: string, "confirm_text": "DELETE" }

POST /api/learner/reset-quest

member (authenticateUser()) + enrollment ownership check

Reset learner quest progress to initial state

Body: { "quest_id": "<uuid>", "confirm_text": "RESET" }

DELETE /api/admin/delete-user

admin (authenticateRole("ADMIN")) + Supabase session + valid reauthToken

Permanently delete a user account and profile

Body: { "userId": "<uuid>", "reauthToken": "<uuid>", "confirm_text": "DELETE" }

Logic & Workflows

  1. Component token gate (AC2):

    1. On every open, useEffect resets confirmText = "" (components/shared/confirm-destructive-modal.tsx:70-72).

    2. Match computation: case-insensitive compares trimmed lowercase; default exact compares raw strings. A non-empty token is required (confirmToken.trim().length > 0) so an untitled draft cannot enable the button on an empty input (:76-83).

    3. showError evaluates to true only after the user types something that does not match (:82); the input receives aria-invalid and a red border (:132-143).

    4. canConfirm is derived as isMatch && !isSubmitting (:83). The destructive button is set to disabled={!canConfirm} (:158); pressing Enter submits only when canConfirm is true (:126-131).

    5. Dialog closing is blocked while isSubmitting is true (:92-95); both action buttons disable, and a spinner plus submittingLabel render (:146-166).

    6. Event propagation for onClick is stopped on the dialog content (:99) so the modal can be nested cleanly inside canvas or drag-and-drop trees.

  2. Permanent delete workflow (single + bulk):

    1. Parent modal opens requiring token "DELETE".

    2. User types "DELETE", setting canConfirm to true, then triggers onConfirm().

    3. Parent client component calls DELETE /api/creator/permanent-delete with { content_type, ids, confirm_text: "DELETE" }.

    4. Route handler validates payload via Zod; on schema failure, returns 400 Invalid permanent delete request payload.

    5. Handler fetches target rows matching the IDs; if missing or empty, returns 404 Content not found or access denied.

    6. Handler executes ownership checks or folder-admin checks per item; on failure, returns 403 You do not have permission to delete this content.

    7. Handler validates confirm_text === "DELETE"; if validation fails, inserts audit_logs record with action = 'PERMANENT_DELETE_FAILED' and returns 400 Please type "DELETE" to confirm permanent deletion.

    8. Handler executes an atomic database delete: DELETE ... WHERE id IN (...) RETURNING id. On empty response or database error, returns 500 Failed to permanently delete content. Please try again. (mapping PGRST116 to "already deleted").

    9. For each deleted ID, handler initiates cleanup of Supabase Storage bucket files under public-assets/<type>/<id> and deletes related asset_metadata rows scoped to the item creator's ID. Cleanup failures are logged with console.error and remain non-fatal.

    10. Parent component displays a success toast, closes the modal, and refreshes the content list.

    • Failure / rollback: The database row deletion is atomic within a single statement, but storage cleanup is not wrapped in a database transaction. If storage cleanup fails, the database record is already deleted, leaving orphaned files in storage.

    • Bulk capacity: permanentDeleteSchema places no upper bound on content_ids. Bulk deletions are processed in a serial for ... await loop.

    • Ownership handling: Storage cleanup uses each item's individual creator_id rather than the requester's ID, ensuring folder administrators deleting another creator's content do not orphan related asset_metadata.

  3. Secure asset purge workflow:

    • Database rows are deleted first from asset_metadata, then storage files are deleted only for records confirmed in deletedRows (app/api/creator/(background-assets)/purge-assets/route.ts:74-91).

    • For video assets, cleanup also attempts to remove derived .jpg thumbnail files (:16-22).

    • Storage cleanup errors log Storage cleanup failed: without failing the HTTP request.

  4. Admin user deletion workflow (chained confirmation → re-auth → delete):

    • The UI at app/admin/manage-users/page.tsx:319-341 renders ConfirmDestructiveModal requiring token "DELETE", followed by ReAuthModal.

    • The user completes the typed confirmation dialog, which invokes handleDeleteConfirm to open the W0.5 re-authentication challenge.

    • Upon successful re-authentication, handleDeleteReAuthSuccess calls DELETE /api/admin/delete-user.

    • Execution sequence inside the endpoint:

      1. Authenticate calling session via authenticateRole("ADMIN") and confirm Supabase session (app/api/admin/delete-user/route.ts:26-28).

      2. Parse { userId, reauthToken, confirm_text: "DELETE" } using Zod (:32-36).

      3. Consume re-authentication token via consumeReAuthToken(clerkId, reauthToken); on failure, write REAUTH_TOKEN_INVALID to audit_logs and return 403 (:41-56).

      4. Fetch target user and roles; refuse deletion if the target holds the ADMIN role (:59-89).

      5. Delete user from Supabase Auth admin API; log and continue on error (:91-99).

      6. Delete user records from agency_members (:102-111).

      7. Delete app_users database row, which cascades to dependent tables (:113-122); on success, write USER_DELETE to audit_logs (:124-136).

    • Failure / rollback: No unified transaction exists across Supabase Auth and the database. If auth deletion succeeds but database deletion fails, the auth identity is lost while database records remain. Migration 20260824_fix_not_null_set_null_user_fks.sql is required so ON DELETE SET NULL on audit_logs.user_id does not throw error 23502.

  5. Learner quest progress reset workflow:

    • components/learner/resetQuest.ts:13-36 acts as the shared helper executing POST /api/learner/reset-quest with confirm_text: "RESET".

    • The helper returns a Promise<boolean>. Consumers (QuestPlayer and ActiveQuestsList) check the boolean before resetting local client-side progress, reloading the player, or dismissing the modal dialog.

    • The endpoint resets quest_enrollments.progress to { percentage: 0, visited_cards: [], last_visited_at: null } (app/api/learner/reset-quest/route.ts:42-53).

    • Milestone XP records are preserved and not re-armed due to the unique constraint on (enrollment_id, milestone) in quest_milestone_earnings.

  6. Folder delete copy correction:

    • Because quests.quest_folder_id is configured with ON DELETE SET NULL, deleting a folder re-parents its contents to the root directory rather than deleting them.

    • FolderCard and FolderTreeView display updated informational copy reflecting this behavior; empty folders delete immediately, while folders containing items or sub-folders trigger the typed-confirmation modal.


Infrastructure & Operations

Dependencies

Upstream:

  • Supabase service-role client (lib/supabase): performs database queries, storage mutations, and audit_logs inserts.

  • Supabase Storage bucket public-assets: stores media files deleted during permanent removal and purge routines.

  • Supabase Auth admin API: provides user account deletion via supabase.auth.admin.deleteUser.

  • Admin re-auth subsystem: admin_reauth_tokens table and consumeReAuthToken helper (lib/auth/reauth).

  • Validation: Zod v4 schemas in shared/schemas/contentManagementSchema.ts and route-level validation definitions.

Downstream:

  • API Contract: ApiResponseHelper formatting JSON responses into W1.4 envelopes (lib/api/response.ts).

  • UI Components: Creator content managers, folder views, learner dashboards, quest players, and administration tables consuming <ConfirmDestructiveModal>.

Monitoring & Alerting

There is no dedicated external alerting or Datadog dashboard wired for this feature. Observability relies on structured log output and rows stored in audit_logs.

Symptom

Likely cause

Fix

Warning: Could not list files in <path>

Storage bucket permission issue or missing folder path

Inspect bucket accessibility and check service-role credentials.

Warning: Failed to delete asset metadata

Database constraint or transient network error during metadata cleanup

Query asset_metadata for orphaned records matching target creator ID.

Warning: Failed to delete some files from storage

Storage object deletion failure

Manually inspect public-assets bucket and purge residual files.

DB permanent deletion failed:

Database constraint violation on quests or adventures

Inspect PostgreSQL logs for foreign key violations.

Storage cleanup failed:

Storage object deletion failure during asset purge

Manually remove unreferenced files from public-assets.

Purge Asset Error:

Unhandled exception in asset purge route handler

Review route logs and check payload structure.

Reset progress error:

Database update failure on quest_enrollments

Confirm record exists and caller has valid enrollment ownership.

Supabase Auth delete error:

GoTrue auth admin API failure

Reconcile the user manually in the Supabase Auth dashboard.

Failed to clean up agency_members:

Database query failure during agency membership cleanup

Query agency_members for records matching user_clerk_id.

Supabase delete error:

Foreign key failure deleting record from app_users

Ensure migration 20260824_fix_not_null_set_null_user_fks.sql is applied.

Unexpected error in delete-user:

Unhandled exception in admin user deletion handler

Check server execution stack trace.

[API ERROR <ISO>] <CODE>: <message>

Uncaught server-side exception formatted by ApiResponseHelper

Inspect error details and stack trace in operational logs.

Key audit_logs action codes to monitor:

  • PERMANENT_DELETE_FAILED: indicates repeated payload validation failures or potential token bypass attempts.

  • USER_DELETE: tracks successful account purges by administrators.

  • REAUTH_TOKEN_INVALID: indicates expired, missing, or reused administrative re-authentication tokens.

Deployment Plan

  • Migration order:

    1. 20260414_create_audit_logs_table.sql

    2. 20260504_create_quest_folder.sql

    3. 20260526_create_admin_reauth_tokens.sql

    4. 20260612_update_rls_policies_multi_role.sql

    5. 20260824_fix_not_null_set_null_user_fks.sql

  • Migration prerequisite: Migration 20260824_fix_not_null_set_null_user_fks.sql must be applied before executing administrative user deletion, or the database delete will fail with error code 23502 due to non-null constraints on audit_logs.user_id.

  • Feature flags: None; the component and endpoint logic ship unconditionally.

  • Backfills: None; historical deletion actions performed prior to this feature are not backfilled into audit_logs.

  • Rollout note: PR #780 was merged to develop (ad657492) and integrated into fix/creator-ui (b65e7991) with a global CSS scroll-lock fix in app/globals.css. Do not re-introduce deleted ad-hoc modals in future rebases.


Testing & Quality Assurance

Test Strategy

  • E2E — Creator Permanent Delete (tests/e2e/creator/permanent-delete.spec.ts:42-83, QA-024): Navigates to Trash, opens confirmation modal, types confirmation token, clicks "Delete Forever", and asserts toast confirmation and item removal.

  • E2E — Archive, Restore, and Delete (tests/e2e/creator/archive-restore-delete.spec.ts:147-184, QA-024): Tests full lifecycle flow from archival to permanent deletion using placeholder matching.

  • E2E — Secure Asset Purge (tests/e2e/creator/secure-asset-delete.spec.ts:96-167, QA-045, QA-046): Verifies that supplying the correct token deletes the asset, while typing an invalid token leaves the button disabled and displays a mismatch hint.

  • E2E — Admin User Delete (tests/e2e/admin/secure-user-delete.spec.ts:7-93, QA-073.5): Asserts user deletion is blocked when an incorrect administrator password is supplied during re-authentication.

  • Static verification: Validated with tsc --noEmit maintaining baseline type-check counts; ESLint passed with 0 errors across modified files; codebase confirmed zero residual imports of the 10 deleted ad-hoc confirmation modals.

Known Limitations

  • Unit test gaps: No isolated unit or component tests exist for <ConfirmDestructiveModal> or its Zod validation schemas (permanentDeleteSchema, purgeAssetsSchema). Edge cases like empty token validation and matchMode handling rely entirely on E2E test runs.

  • Stale E2E test selectors: Existing tests in tests/e2e/creator/permanent-delete.spec.ts:69 target #confirm-delete, and tests/e2e/creator/secure-asset-delete.spec.ts:107,145 target #confirm-purge-asset. The unified component hardcodes id="confirm-destructive" (components/shared/confirm-destructive-modal.tsx:122), causing older selectors to mismatch.

  • Hardcoded input identifier: The modal input fixed attribute id="confirm-destructive" causes duplicate DOM IDs and ambiguous label associations if multiple modal instances mount concurrently.

  • Partial server-side enforcement (AC3): Server-side token re-validation is implemented only on the four high-risk endpoints (permanent-delete, purge-assets, reset-quest, delete-user). Approximately 13 other destructive operations across the platform remain client-gated only.

  • Schema mismatch on permanentDeleteSchema: In shared/schemas/contentManagementSchema.ts:61, confirm_text is defined as z.string().min(1) rather than z.literal("DELETE"). Exact matching is enforced imperatively in the route handler (permanent-delete/route.ts:134).

  • Unused field in purge route: purge-assets validates confirm_text via purgeAssetsSchema, but the route handler destructures only asset_id and asset_ids, relying entirely on schema validation.

  • Unbounded bulk deletion arrays: Schemas do not enforce maximum array limits on content_ids or asset_ids, allowing large payloads that execute unbounded serial cleanup operations.

  • Non-transactional storage cleanup: Database row deletions and storage object removals are not wrapped in a distributed transaction; failures during storage cleanup can leave orphaned files in public-assets.

  • Missing rate limits: No dedicated rate-limiting or re-authentication rules protect permanent-delete, purge-assets, or reset-quest.

  • Inconsistent busy-state UI: Several parent components (FolderCard, FolderTreeView, SettingsDropdown, ArchivedNodesModal) do not yet pass isSubmitting to <ConfirmDestructiveModal>, causing the dialog to dismiss without inline loading indicators.

  • Accessibility: The input element relies on DialogDescription and aria-invalid, but does not link error messages via aria-describedby.

  • Product acceptance documentation: Acceptance verification from product management for Issue #777 remains undocumented in the repository.


Maintenance & Support

Troubleshooting

Symptom

Likely cause

Fix

Text does not match. Type exactly "<token>".

Entered text does not match confirmToken according to matchMode

Verify expected token string passed to <ConfirmDestructiveModal> (components/shared/confirm-destructive-modal.tsx:141).

Please type "DELETE" to confirm permanent deletion

Request payload sent confirm_text !== "DELETE" to /api/creator/permanent-delete

Verify client caller sends the literal "DELETE"; inspect audit_logs for PERMANENT_DELETE_FAILED (permanent-delete/route.ts:134-149).

Invalid permanent delete request payload

Zod schema parse error: malformed UUID, missing IDs, empty confirm_text, or invalid content_type

Inspect error.details in response body to review field parsing failures (:47-52).

Invalid permanent purge request payload

Payload failed purgeAssetsSchema: missing asset IDs or confirm_text !== "DELETE"

Ensure payload adheres to purgeAssetsSchema (shared/schemas/contentManagementSchema.ts:96-98).

No content IDs provided / No asset IDs provided

Payload omitted both singular and array ID properties

Update request payload to provide at least one valid target ID (permanent-delete/route.ts:65, purge-assets/route.ts:57).

You do not have permission to delete this content

Caller lacks creator ownership and is not an authorized folder manager

Verify creator_id matches requester or verify user has folder management rights (permanent-delete/route.ts:117-125).

Content not found. It may have been already deleted.

Target record missing during deletion query (PGRST116)

Concurrently deleted or stale client state; refresh UI content list (:161-163).

Content not found or access denied / Assets not found

Initial ID lookup returned zero rows

Item is already deleted or access is restricted by RLS policies (:79, purge:66).

Failed to permanently delete content. Please try again.

Unhandled database error occurred during deletion

Review PostgreSQL logs for foreign key constraint errors (:164).

Failed to delete from database

Database error deleting row from asset_metadata

Inspect database error; note that storage deletion is intentionally skipped if DB delete fails (purge-assets/route.ts:74-85).

Enrollment not found

No record found in quest_enrollments matching quest_id and authenticated learner

Confirm learner is actively enrolled in target quest (reset-quest/route.ts:37-39).

Failed to reset quest progress

Database update failed on quest_enrollments.progress

Check Reset progress error: log in server output (:55-60).

Invalid input

Schema parse failure on delete-user: invalid UUIDs or confirm_text !== "DELETE"

Review error.details for schema validation failures (delete-user/route.ts:34-36).

Re-authentication token is invalid or expired. Please re-authenticate.

Admin re-auth token missing, expired (> 5 min), already consumed, or generated by another user

Re-open re-authentication modal to generate a new token (:41-56).

Cannot delete admin users

Target account holds the ADMIN platform role

Deletion of admin users is prohibited; select a non-admin account (:86-89).

Failed to delete user

Database deletion error on app_users table

Verify migration 20260824_fix_not_null_set_null_user_fks.sql is applied to allow ON DELETE SET NULL on audit_logs (:119-122).

Supabase Auth delete error: log appears, but user removed from DB

Auth user deletion failed, but DB delete proceeded by design

Manually remove residual auth account from Supabase Auth dashboard (:94-98).

Warning: Failed to delete some files from storage / Storage cleanup failed:

Database row deleted, but Storage object removal failed

Inspect public-assets bucket and delete orphaned files manually (permanent-delete/route.ts:36-38, purge:28).

[API ERROR <ISO>] INTERNAL_ERROR: …

Unhandled server runtime exception

Inspect attached stack trace in operational error logs (lib/api/response.ts:125-130).

E2E test fails to locate confirmation input

Test script targets old modal element ID (#confirm-delete or #confirm-purge-asset)

Update test selector to locate #confirm-destructive (components/shared/confirm-destructive-modal.tsx:122).

Changelog

  • August 21, 2026 — merge: integrate reusable confirm-destructive modal, fix sticky layout break at root — accepted PR #780's overhaul; fixed Radix scroll-lock at root via app/globals.css body[data-scroll-locked] instead of disabling modal mode on fix/creator-ui (b65e7991).

  • August 20, 2026 — feat: standardize destructive actions on shared ConfirmDestructiveModal — adds components/shared/confirm-destructive-modal.tsx; server token double-checks on permanent-delete / purge-assets / reset-quest / delete-user; purgeAssetsSchema; ApiResponseHelper envelope for purge; resetQuestProgress helper; deletes 9 ad-hoc modals; 44 files changed, +988/−1324 on feat/reusable-confirm-destructive-modal (13373adb).

  • August 20, 2026 — feat: adopt ConfirmDestructiveModal in learner groups + content library — DeleteGroupDialog rebuilt as thin wrapper with extraContent reassign select; GroupMembersTable, GroupEnrollmentTable, learner-groups unassign, AgencyBadgeRequirements, ContentListRow, ContentLibraryPage, FolderWrapper; deletes FolderDeleteConfirmationModal (10th orphan); 9 files, +275/−378 on feat/reusable-confirm-destructive-modal (1210c258).

  • August 20, 2026 — fix: address round-1 review findings on confirm-destructive-modal — resetQuestProgress returns boolean and callers gate client state (blocker); non-empty token guard; folder-delete copy correction + instant delete for empty folders; busy-state modals kept open; restored PostDetail copy feedback on feat/reusable-confirm-destructive-modal (07530207).

  • August 20, 2026 — Merge pull request #780 from wyzlab/feat/reusable-confirm-destructive-modal on develop (ad657492).


Document version:

1.0 - Draft, Initial Technical Guide, 07/29/2026

1.1 - Published, Reusable Confirm-Destructive Modal Technical Guide, 07/29/2026


Was this article helpful?