bug: deleting a user in the admin panel does nothing #277

Closed
opened 2026-04-19 16:34:22 +02:00 by marcel · 2 comments
Owner

Description

Clicking "Löschen…" on a user's detail page in the admin panel appears to do nothing. Investigation found two concrete backend bugs that block deletion, plus a migration bug that would prevent fresh-database startup.


Root cause analysis

1. invite_tokens.created_by — missing ON DELETE clause (most likely cause of silent failure)

File: backend/src/main/resources/db/migration/V45__add_invite_tokens.sql

created_by    UUID         NOT NULL REFERENCES users(id),

No ON DELETE clause defaults to RESTRICT. If the user being deleted has ever created an invite token, Postgres will reject the DELETE with a foreign key violation. Spring Boot returns a raw 500 (not a DomainException), so the frontend shows a generic "Interner Fehler" banner inside the scrollable form body — easy to miss. The fix is ON DELETE SET NULL (keeping invite history, just losing attribution) or ON DELETE CASCADE (deleting their invites too).

2. V46 migration references a non-existent table

File: backend/src/main/resources/db/migration/V46__add_audit_log.sql

actor_id    UUID        REFERENCES app_users(id) ON DELETE SET NULL,

The AppUser entity is mapped to @Table(name = "users"), not app_users. This FK reference is wrong and would cause Flyway to fail on any fresh database initialization, preventing app startup entirely. Should be:

actor_id    UUID        REFERENCES users(id) ON DELETE SET NULL,

Fix checklist

  • Add a new migration V47__fix_invite_tokens_cascade.sql that changes invite_tokens.created_by to ON DELETE SET NULL (or CASCADE — decide first)
  • Fix the FK reference in V46__add_audit_log.sql: app_users(id)users(id) (requires resetting/recreating the DB if already applied, or adding a corrective migration V47 if the column exists)
  • Verify deletion works end-to-end in the admin panel after the fix
## Description Clicking "Löschen…" on a user's detail page in the admin panel appears to do nothing. Investigation found two concrete backend bugs that block deletion, plus a migration bug that would prevent fresh-database startup. --- ## Root cause analysis ### 1. `invite_tokens.created_by` — missing ON DELETE clause (most likely cause of silent failure) **File:** `backend/src/main/resources/db/migration/V45__add_invite_tokens.sql` ```sql created_by UUID NOT NULL REFERENCES users(id), ``` No `ON DELETE` clause defaults to `RESTRICT`. If the user being deleted has ever created an invite token, Postgres will reject the `DELETE` with a foreign key violation. Spring Boot returns a raw 500 (not a `DomainException`), so the frontend shows a generic "Interner Fehler" banner inside the scrollable form body — easy to miss. The fix is `ON DELETE SET NULL` (keeping invite history, just losing attribution) or `ON DELETE CASCADE` (deleting their invites too). ### 2. V46 migration references a non-existent table **File:** `backend/src/main/resources/db/migration/V46__add_audit_log.sql` ```sql actor_id UUID REFERENCES app_users(id) ON DELETE SET NULL, ``` The `AppUser` entity is mapped to `@Table(name = "users")`, not `app_users`. This FK reference is wrong and would cause Flyway to fail on any fresh database initialization, preventing app startup entirely. Should be: ```sql actor_id UUID REFERENCES users(id) ON DELETE SET NULL, ``` --- ## Fix checklist - [ ] Add a new migration `V47__fix_invite_tokens_cascade.sql` that changes `invite_tokens.created_by` to `ON DELETE SET NULL` (or CASCADE — decide first) - [ ] Fix the FK reference in `V46__add_audit_log.sql`: `app_users(id)` → `users(id)` (requires resetting/recreating the DB if already applied, or adding a corrective migration V47 if the column exists) - [ ] Verify deletion works end-to-end in the admin panel after the fix
marcel added the buguser labels 2026-04-19 16:34:26 +02:00
Author
Owner

Updated analysis

The invite_tokens constraint is not the cause — the user being deleted only accepted an invite, not created one. And there was no error banner, so the server action either didn't run or the backend succeeded but the navigation was silently blocked.

Likely root cause: beforeNavigate in useUnsavedWarning cancels the post-delete redirect

frontend/src/lib/hooks/useUnsavedWarning.svelte.ts:

beforeNavigate(({ cancel, to }) => {
    if (isDirty) {
        cancel();
        showUnsavedWarning = true;
        discardTarget = to?.url.href ?? null;
    }
});

isDirty becomes true the moment any input event fires on the edit form (oninput={unsaved.markDirty}). This can happen without the user deliberately typing — browser autofill (e.g., password field) is enough.

Flow when isDirty is true at delete time:

  1. User confirms deletion in the dialog
  2. Delete form submits → server action runs → user is deleted from the DB
  3. Server action throws redirect(303, '/admin/users')
  4. SvelteKit's enhance tries to navigate
  5. beforeNavigate fires, sees isDirty = true, cancels navigation
  6. showUnsavedWarning = true — an "unsaved changes" banner appears in the scrollable form body
  7. The page stays on /admin/users/[id], list doesn't update

The user sees: confirm dialog disappeared, page unchanged → "nothing happened". The unsaved-changes banner is not an error banner, so easy to overlook. Meanwhile the user has already been deleted.

Fix

Clear isDirty before submitting the delete form in +page.svelte:

async function handleDelete() {
    const confirmed = await confirm({
        title: m.admin_user_delete_confirm({ username: data.editUser.email }),
        destructive: true
    });
    if (confirmed) {
        unsaved.clearOnSuccess(); // prevent beforeNavigate from blocking the redirect
        deleteFormEl!.requestSubmit();
    }
}

clearOnSuccess() already sets isDirty = false and showUnsavedWarning = false, so this reuses the existing API.


The two migration bugs from the original report (V46 wrong FK table name, invite_tokens missing ON DELETE) are separate issues and still need fixing — but they are not the cause of this "nothing happens" symptom.

## Updated analysis The `invite_tokens` constraint is **not** the cause — the user being deleted only accepted an invite, not created one. And there was **no error banner**, so the server action either didn't run or the backend succeeded but the navigation was silently blocked. ### Likely root cause: `beforeNavigate` in `useUnsavedWarning` cancels the post-delete redirect `frontend/src/lib/hooks/useUnsavedWarning.svelte.ts`: ```typescript beforeNavigate(({ cancel, to }) => { if (isDirty) { cancel(); showUnsavedWarning = true; discardTarget = to?.url.href ?? null; } }); ``` `isDirty` becomes `true` the moment any `input` event fires on the edit form (`oninput={unsaved.markDirty}`). This can happen without the user deliberately typing — browser autofill (e.g., password field) is enough. Flow when `isDirty` is true at delete time: 1. User confirms deletion in the dialog 2. Delete form submits → server action runs → **user is deleted from the DB** 3. Server action throws `redirect(303, '/admin/users')` 4. SvelteKit's `enhance` tries to navigate 5. `beforeNavigate` fires, sees `isDirty = true`, **cancels navigation** 6. `showUnsavedWarning = true` — an "unsaved changes" banner appears in the scrollable form body 7. The page stays on `/admin/users/[id]`, list doesn't update The user sees: confirm dialog disappeared, page unchanged → "nothing happened". The unsaved-changes banner is not an error banner, so easy to overlook. Meanwhile the user **has already been deleted**. ### Fix Clear `isDirty` before submitting the delete form in `+page.svelte`: ```typescript async function handleDelete() { const confirmed = await confirm({ title: m.admin_user_delete_confirm({ username: data.editUser.email }), destructive: true }); if (confirmed) { unsaved.clearOnSuccess(); // prevent beforeNavigate from blocking the redirect deleteFormEl!.requestSubmit(); } } ``` `clearOnSuccess()` already sets `isDirty = false` and `showUnsavedWarning = false`, so this reuses the existing API. --- The two migration bugs from the original report (V46 wrong FK table name, invite_tokens missing ON DELETE) are **separate issues** and still need fixing — but they are not the cause of this "nothing happens" symptom.
Author
Owner

Further update

The user is still present after a full page refresh → the backend DELETE was never executed. This rules out the beforeNavigate theory too.

Actual root cause: wrong usage of the confirm service with use:enhance

The current approach in +page.svelte:

<!-- form in header -->
<form bind:this={deleteFormEl} method="POST" action="?/delete" use:enhance>
    <button type="button" onclick={handleDelete}>…</button>
</form>
async function handleDelete() {
    const confirmed = await confirm({...});
    if (confirmed) deleteFormEl!.requestSubmit();
}

The problem: requestSubmit() fires a submit event that SvelteKit's enhance should intercept — but this indirect, async-separated approach is fragile. It's not the pattern the confirm service was designed for. The confirm.svelte.ts module itself documents the correct pattern in its JSDoc (lines 22–27):

// ## Usage with use:enhance
// <form use:enhance={async ({ cancel }) => {
//   const ok = await confirm({ title: m.confirm_delete_title(), destructive: true });
//   if (!ok) cancel();
// }}>

Fix

Use a real type="submit" button and move the confirm guard into the enhance callback:

<form
    method="POST"
    action="?/delete"
    use:enhance={async ({ cancel }) => {
        const confirmed = await confirm({
            title: m.admin_user_delete_confirm({ username: data.editUser.email }),
            destructive: true
        });
        if (!confirmed) cancel();
        else unsaved.clearOnSuccess(); // so beforeNavigate doesn't block the redirect
    }}
>
    <button
        type="submit"
        class="rounded-sm border border-red-200 bg-red-50 px-3 py-1 font-sans text-xs font-bold tracking-widest text-red-700 uppercase transition-colors hover:bg-red-100 dark:border-red-900 dark:bg-red-950/30 dark:text-red-400"
    >
        {m.btn_delete()}</button>
</form>

No bind:this, no requestSubmit(), no extra event handler needed. The enhance callback awaits the dialog, cancels if rejected, otherwise lets the form submit proceed normally. The unsaved.clearOnSuccess() call ensures beforeNavigate doesn't cancel the post-delete redirect if the user happened to touch the edit form.

## Further update The user is still present after a full page refresh → **the backend DELETE was never executed**. This rules out the `beforeNavigate` theory too. ### Actual root cause: wrong usage of the confirm service with `use:enhance` The current approach in `+page.svelte`: ```svelte <!-- form in header --> <form bind:this={deleteFormEl} method="POST" action="?/delete" use:enhance> <button type="button" onclick={handleDelete}>…</button> </form> ``` ```typescript async function handleDelete() { const confirmed = await confirm({...}); if (confirmed) deleteFormEl!.requestSubmit(); } ``` The problem: `requestSubmit()` fires a `submit` event that SvelteKit's `enhance` should intercept — but this indirect, async-separated approach is fragile. It's not the pattern the confirm service was designed for. The `confirm.svelte.ts` module itself documents the correct pattern in its JSDoc (lines 22–27): ```svelte // ## Usage with use:enhance // <form use:enhance={async ({ cancel }) => { // const ok = await confirm({ title: m.confirm_delete_title(), destructive: true }); // if (!ok) cancel(); // }}> ``` ### Fix Use a real `type="submit"` button and move the confirm guard into the `enhance` callback: ```svelte <form method="POST" action="?/delete" use:enhance={async ({ cancel }) => { const confirmed = await confirm({ title: m.admin_user_delete_confirm({ username: data.editUser.email }), destructive: true }); if (!confirmed) cancel(); else unsaved.clearOnSuccess(); // so beforeNavigate doesn't block the redirect }} > <button type="submit" class="rounded-sm border border-red-200 bg-red-50 px-3 py-1 font-sans text-xs font-bold tracking-widest text-red-700 uppercase transition-colors hover:bg-red-100 dark:border-red-900 dark:bg-red-950/30 dark:text-red-400" > {m.btn_delete()}… </button> </form> ``` No `bind:this`, no `requestSubmit()`, no extra event handler needed. The `enhance` callback awaits the dialog, cancels if rejected, otherwise lets the form submit proceed normally. The `unsaved.clearOnSuccess()` call ensures `beforeNavigate` doesn't cancel the post-delete redirect if the user happened to touch the edit form.
Sign in to join this conversation.
No Label bug user
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#277