fix(frontend): confirm modal, settings display, race guard, and test coverage
- S1: Replace window.confirm() with AppConfirmModal for delete actions - S4: Add formatHours helper to avoid ugly decimals in settings hints - W5: Add generation counter to SettingsAdminPanel to prevent stale async results when section changes rapidly - S7: Remove unused ApiRequestError import from useScholarBulkActions - S2: Add error-path tests for bulk delete/toggle network failures - S3: Add CSRF boundary tests for bulk-delete and bulk-toggle endpoints
This commit is contained in:
parent
f8e3098fc3
commit
f501ea4f94
8 changed files with 181 additions and 33 deletions
|
|
@ -53,12 +53,12 @@ def _parse_ids_param(ids: str | None) -> list[int] | None:
|
|||
return None
|
||||
try:
|
||||
return [int(p) for p in parts]
|
||||
except ValueError:
|
||||
except ValueError as exc:
|
||||
raise ApiException(
|
||||
status_code=400,
|
||||
code="invalid_ids_param",
|
||||
message="The 'ids' parameter must be a comma-separated list of integers.",
|
||||
)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
|
|
|
|||
28
frontend/src/components/ui/AppConfirmModal.vue
Normal file
28
frontend/src/components/ui/AppConfirmModal.vue
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<script setup lang="ts">
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppModal from "@/components/ui/AppModal.vue";
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: "danger" | "default";
|
||||
}>(),
|
||||
{ confirmLabel: "Confirm", cancelLabel: "Cancel", variant: "default" },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ confirm: []; cancel: [] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppModal :open="open" :title="title" @close="emit('cancel')">
|
||||
<p class="mb-6 text-sm text-secondary">{{ message }}</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" @click="emit('cancel')">{{ cancelLabel }}</AppButton>
|
||||
<AppButton :variant="variant === 'danger' ? 'danger' : 'primary'" @click="emit('confirm')">{{ confirmLabel }}</AppButton>
|
||||
</div>
|
||||
</AppModal>
|
||||
</template>
|
||||
|
|
@ -109,4 +109,40 @@ describe("useScholarBulkActions", () => {
|
|||
await bulk.onApplyBulkAction();
|
||||
expect(bulk.selectedIds.value.size).toBe(0);
|
||||
});
|
||||
|
||||
it("bulk delete calls assignError on network failure", async () => {
|
||||
const { bulkDeleteScholars } = await import("@/features/scholars");
|
||||
vi.mocked(bulkDeleteScholars).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
const { bulk, callbacks } = setup([makeProfile(1, "Alice")]);
|
||||
bulk.onToggleRow(1, { target: { checked: true } } as unknown as Event);
|
||||
bulk.bulkAction.value = "delete_selected" as ScholarBulkAction;
|
||||
await bulk.onApplyBulkAction();
|
||||
|
||||
// Trigger the confirm callback
|
||||
expect(bulk.confirmState.value.open).toBe(true);
|
||||
await bulk.confirmState.value.onConfirm();
|
||||
|
||||
expect(callbacks.assignError).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
"Unable to bulk delete scholars.",
|
||||
);
|
||||
expect(bulk.bulkBusy.value).toBe(false);
|
||||
});
|
||||
|
||||
it("bulk toggle calls assignError on network failure", async () => {
|
||||
const { bulkToggleScholars } = await import("@/features/scholars");
|
||||
vi.mocked(bulkToggleScholars).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
const { bulk, callbacks } = setup([makeProfile(1, "Alice")]);
|
||||
bulk.onToggleRow(1, { target: { checked: true } } as unknown as Event);
|
||||
bulk.bulkAction.value = "enable_selected" as ScholarBulkAction;
|
||||
await bulk.onApplyBulkAction();
|
||||
|
||||
expect(callbacks.assignError).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
"Unable to bulk enable scholars.",
|
||||
);
|
||||
expect(bulk.bulkBusy.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
exportScholarData,
|
||||
type ScholarProfile,
|
||||
} from "@/features/scholars";
|
||||
import { ApiRequestError } from "@/lib/api/errors";
|
||||
|
||||
export type ScholarBulkAction =
|
||||
| "delete_selected"
|
||||
|
|
@ -21,6 +20,14 @@ export interface ScholarBulkActionOption {
|
|||
label: string;
|
||||
}
|
||||
|
||||
export interface ConfirmState {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
variant: "danger" | "default";
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export interface BulkActionCallbacks {
|
||||
clearMessages: () => void;
|
||||
assignError: (error: unknown, fallback: string) => void;
|
||||
|
|
@ -35,6 +42,7 @@ export function useScholarBulkActions(
|
|||
const selectedIds = ref<Set<number>>(new Set());
|
||||
const bulkAction = ref<ScholarBulkAction>("select_all");
|
||||
const bulkBusy = ref(false);
|
||||
const confirmState = ref<ConfirmState>({ open: false, title: "", message: "", variant: "default", onConfirm: () => {} });
|
||||
|
||||
const selectedCount = computed(() => selectedIds.value.size);
|
||||
const hasSelection = computed(() => selectedCount.value > 0);
|
||||
|
|
@ -128,9 +136,22 @@ export function useScholarBulkActions(
|
|||
if (bulkAction.value === "export_selected") { await onBulkExport(); return; }
|
||||
}
|
||||
|
||||
async function onBulkDelete(): Promise<void> {
|
||||
function dismissConfirm(): void {
|
||||
confirmState.value = { ...confirmState.value, open: false };
|
||||
}
|
||||
|
||||
function requestConfirm(title: string, message: string, variant: "danger" | "default", onConfirm: () => void): void {
|
||||
confirmState.value = { open: true, title, message, variant, onConfirm };
|
||||
}
|
||||
|
||||
function onBulkDelete(): void {
|
||||
const ids = [...selectedIds.value];
|
||||
if (!window.confirm(`Delete ${ids.length} scholar(s)? This removes all linked publications and queue data.`)) return;
|
||||
requestConfirm(
|
||||
`Delete ${ids.length} scholar(s)?`,
|
||||
"This removes all linked publications and queue data. This action cannot be undone.",
|
||||
"danger",
|
||||
async () => {
|
||||
dismissConfirm();
|
||||
bulkBusy.value = true;
|
||||
callbacks.clearMessages();
|
||||
try {
|
||||
|
|
@ -143,6 +164,8 @@ export function useScholarBulkActions(
|
|||
} finally {
|
||||
bulkBusy.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function onBulkToggle(isEnabled: boolean): Promise<void> {
|
||||
|
|
@ -188,6 +211,9 @@ export function useScholarBulkActions(
|
|||
bulkActionOptions,
|
||||
bulkApplyLabel,
|
||||
bulkApplyDisabled,
|
||||
confirmState,
|
||||
dismissConfirm,
|
||||
requestConfirm,
|
||||
onToggleAll,
|
||||
onToggleRow,
|
||||
onApplyBulkAction,
|
||||
|
|
|
|||
|
|
@ -25,7 +25,10 @@ const integrityRef = ref<InstanceType<typeof AdminIntegritySection> | null>(null
|
|||
const repairsRef = ref<InstanceType<typeof AdminRepairsSection> | null>(null);
|
||||
const pdfQueueRef = ref<InstanceType<typeof AdminPdfQueueSection> | null>(null);
|
||||
|
||||
let loadGeneration = 0;
|
||||
|
||||
async function loadSection(): Promise<void> {
|
||||
const gen = ++loadGeneration;
|
||||
clearAlerts();
|
||||
try {
|
||||
if (props.section === SECTION_USERS && usersRef.value) {
|
||||
|
|
@ -34,11 +37,13 @@ async function loadSection(): Promise<void> {
|
|||
await integrityRef.value.load();
|
||||
} else if (props.section === SECTION_REPAIRS) {
|
||||
await usersRef.value?.load();
|
||||
if (gen !== loadGeneration) return;
|
||||
await repairsRef.value?.load();
|
||||
} else if (props.section === SECTION_PDF && pdfQueueRef.value) {
|
||||
await pdfQueueRef.value.load();
|
||||
}
|
||||
} catch (error) {
|
||||
if (gen !== loadGeneration) return;
|
||||
assignError(error, "Unable to load admin data.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
|
|||
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppCard from "@/components/ui/AppCard.vue";
|
||||
import AppConfirmModal from "@/components/ui/AppConfirmModal.vue";
|
||||
import AppEmptyState from "@/components/ui/AppEmptyState.vue";
|
||||
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||
import AppInput from "@/components/ui/AppInput.vue";
|
||||
|
|
@ -272,11 +273,16 @@ async function onToggleScholar(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
async function onDeleteScholar(): Promise<void> {
|
||||
function onDeleteScholar(): void {
|
||||
const profile = activeScholarSettings.value;
|
||||
if (!profile) return;
|
||||
const label = scholarLabel(profile);
|
||||
if (!window.confirm(`Delete scholar ${label}? This removes all linked publications and queue data.`)) return;
|
||||
bulk.requestConfirm(
|
||||
`Delete ${label}?`,
|
||||
"This removes all linked publications and queue data. This action cannot be undone.",
|
||||
"danger",
|
||||
async () => {
|
||||
bulk.dismissConfirm();
|
||||
activeScholarId.value = profile.id;
|
||||
clearMessages();
|
||||
try {
|
||||
|
|
@ -289,6 +295,8 @@ async function onDeleteScholar(): Promise<void> {
|
|||
} finally {
|
||||
activeScholarId.value = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function onSaveImageUrl(): Promise<void> {
|
||||
|
|
@ -597,6 +605,16 @@ watch(
|
|||
@toggle="onToggleScholar"
|
||||
@delete="onDeleteScholar"
|
||||
/>
|
||||
|
||||
<AppConfirmModal
|
||||
:open="bulk.confirmState.value.open"
|
||||
:title="bulk.confirmState.value.title"
|
||||
:message="bulk.confirmState.value.message"
|
||||
:variant="bulk.confirmState.value.variant"
|
||||
confirm-label="Delete"
|
||||
@confirm="bulk.confirmState.value.onConfirm()"
|
||||
@cancel="bulk.dismissConfirm()"
|
||||
/>
|
||||
</AppPage>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -168,6 +168,11 @@ function parseBoundedInteger(value: string, label: string, minimum: number): num
|
|||
return parsed;
|
||||
}
|
||||
|
||||
function formatHours(minutes: number): string {
|
||||
const h = minutes / 60;
|
||||
return Number.isInteger(h) ? String(h) : h.toFixed(2).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
function parseHoursToMinutes(value: string, minMinutes: number): number {
|
||||
const hours = Number(value);
|
||||
if (!Number.isFinite(hours) || hours <= 0) {
|
||||
|
|
@ -175,7 +180,7 @@ function parseHoursToMinutes(value: string, minMinutes: number): number {
|
|||
}
|
||||
const minutes = Math.round(hours * 60);
|
||||
if (minutes < minMinutes) {
|
||||
throw new Error(`Check interval must be at least ${minMinutes / 60} hours.`);
|
||||
throw new Error(`Check interval must be at least ${formatHours(minMinutes)} hours.`);
|
||||
}
|
||||
return minutes;
|
||||
}
|
||||
|
|
@ -372,7 +377,7 @@ onMounted(async () => {
|
|||
<AppHelpHint text="Minimum is controlled by server policy." />
|
||||
</span>
|
||||
<AppInput v-model="runIntervalHours" inputmode="decimal" />
|
||||
<span class="text-xs text-secondary">Minimum: {{ minCheckIntervalMinutes / 60 }} hours</span>
|
||||
<span class="text-xs text-secondary">Minimum: {{ formatHours(minCheckIntervalMinutes) }} hours</span>
|
||||
</label>
|
||||
|
||||
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
||||
|
|
|
|||
|
|
@ -138,3 +138,33 @@ async def test_export_with_ids_filter(db_session: AsyncSession) -> None:
|
|||
resp_all = client.get("/api/v1/scholars/export")
|
||||
assert resp_all.status_code == 200
|
||||
assert len(resp_all.json()["data"]["scholars"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_rejects_without_csrf(db_session: AsyncSession) -> None:
|
||||
await insert_user(db_session, email="csrf-bulk-del@example.com", password="pw123456")
|
||||
client = TestClient(app)
|
||||
login_user(client, email="csrf-bulk-del@example.com", password="pw123456")
|
||||
response = client.post(
|
||||
"/api/v1/scholars/bulk-delete",
|
||||
json={"scholar_profile_ids": [1]},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["error"]["code"] == "csrf_invalid"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_toggle_rejects_without_csrf(db_session: AsyncSession) -> None:
|
||||
await insert_user(db_session, email="csrf-bulk-tog@example.com", password="pw123456")
|
||||
client = TestClient(app)
|
||||
login_user(client, email="csrf-bulk-tog@example.com", password="pw123456")
|
||||
response = client.post(
|
||||
"/api/v1/scholars/bulk-toggle",
|
||||
json={"scholar_profile_ids": [1], "is_enabled": False},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["error"]["code"] == "csrf_invalid"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue