Add research deletion protection interface

This commit is contained in:
faligam
2026-09-07 12:34:47 +02:00
parent c5bc74a127
commit a868280766
7 changed files with 145 additions and 20 deletions

View File

@@ -45,11 +45,13 @@ export async function apiGet<T>(
export async function apiDelete<T>(
path: string,
config?: ApiConfig
config?: ApiConfig,
body?: unknown
): Promise<T> {
const res = await fetch(`${API_BASE_URL}${path}`, {
method: 'DELETE',
headers: headers(config)
headers: headers(config),
body: body ? JSON.stringify(body) : undefined
});
if (!res.ok) {
throw new Error(`API Error ${res.status}: ${res.statusText}`);

View File

@@ -26,6 +26,7 @@
<span class="text-xs text-gray-400 whitespace-nowrap">{formatDate(item.created_at)}</span>
</div>
<p class="text-sm text-gray-700 dark:text-gray-300 truncate" title={item.query}>
{#if item.is_deletion_protected}<span aria-label="Löschschutz aktiv" title="Löschschutz aktiv">🔒 </span>{/if}
{item.query}
</p>
<div class="flex items-center justify-between">

View File

@@ -121,6 +121,10 @@
</a>
</div>
<div class="px-4 py-2 border-b border-gray-200 dark:border-gray-700">
<a href="/admin/deleted" class="text-xs text-gray-500 hover:text-indigo-600 dark:text-gray-400">Papierkorb (Admin)</a>
</div>
<!-- History list -->
<div class="flex-1 overflow-y-auto px-2 py-2 space-y-1 min-h-0">
{#if loading}

View File

@@ -10,6 +10,7 @@ interface ApiResearchStatus {
updated_at?: string;
source_count?: number;
claim_count?: number;
is_deletion_protected?: boolean;
}
interface ApiSourcesResponse {
@@ -70,6 +71,7 @@ function normalizeDetail(status: ApiResearchStatus): ResearchDetail {
status: status.state.toUpperCase(),
created_at: status.created_at,
completed_at: status.updated_at,
is_deletion_protected: status.is_deletion_protected ?? false,
};
}
@@ -159,6 +161,7 @@ export async function listResearch(
query: item.query,
status: item.state.toUpperCase(),
created_at: item.created_at,
is_deletion_protected: item.is_deletion_protected ?? false,
}));
}
@@ -173,13 +176,37 @@ export async function getResearchReport(
}
/**
* Löscht eine Research-Abfrage permanent.
* Blendet eine Research-Abfrage aus. Sie bleibt für Administratoren im Papierkorb.
*/
export async function deleteResearch(
apiKey: string,
researchId: string
researchId: string,
password?: string
): Promise<void> {
await apiDelete<void>(`/v1/research/${researchId}`, { apiKey });
await apiDelete<void>(`/v1/research/${researchId}`, { apiKey }, password ? { password } : undefined);
}
export async function setDeletionProtection(
apiKey: string, researchId: string, password: string, currentPassword?: string
): Promise<void> {
await apiPost<void>(`/v1/research/${researchId}/deletion-protection`, {
password, current_password: currentPassword,
}, { apiKey });
}
export interface DeletedResearch {
research_id: string;
query: string;
hidden_at: string;
is_deletion_protected: boolean;
}
export async function listDeletedResearch(apiKey: string): Promise<DeletedResearch[]> {
return await apiGet<DeletedResearch[]>('/v1/admin/research/deleted', { apiKey });
}
export async function purgeDeletedResearch(apiKey: string, researchId: string): Promise<void> {
await apiDelete<void>(`/v1/admin/research/${researchId}`, { apiKey });
}
/**
@@ -255,6 +282,7 @@ export interface ResearchDetail {
methodology?: string;
plan?: ResearchPlan;
username?: string;
is_deletion_protected?: boolean;
}
export interface ResearchPlan {

View File

@@ -11,6 +11,7 @@ export interface ResearchSummary {
status: string;
created_at: string;
completed_at?: string;
is_deletion_protected?: boolean;
}
export interface SourceInfo {

View File

@@ -0,0 +1,57 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getApiKey } from '$lib/auth';
import { listDeletedResearch, purgeDeletedResearch, type DeletedResearch } from '$lib/research-api';
let items: DeletedResearch[] = [];
let error = '';
let loading = true;
async function load() {
const apiKey = getApiKey();
if (!apiKey) {
error = 'Kein API-Key verfügbar.';
loading = false;
return;
}
try {
items = await listDeletedResearch(apiKey);
} catch (e) {
error = e instanceof Error ? 'Papierkorb kann nur von Administratoren geöffnet werden.' : 'Papierkorb konnte nicht geladen werden.';
} finally {
loading = false;
}
}
async function purge(item: DeletedResearch) {
if (!window.confirm(`„${item.query}“ endgültig löschen? Diese Aktion kann nicht rückgängig gemacht werden.`)) return;
const apiKey = getApiKey();
if (!apiKey) return;
try {
await purgeDeletedResearch(apiKey, item.research_id);
items = items.filter(candidate => candidate.research_id !== item.research_id);
} catch (e) {
error = e instanceof Error ? e.message : 'Endgültige Löschung fehlgeschlagen.';
}
}
onMount(load);
</script>
<div class="flex-1 overflow-y-auto p-6 max-w-4xl">
<h1 class="text-xl font-bold text-gray-900 dark:text-white">Papierkorb (Administration)</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Soft-gelöschte Recherchen. Eine endgültige Löschung wird protokolliert.</p>
{#if loading}<p class="mt-6 text-sm text-gray-500">Lade Papierkorb …</p>
{:else if error}<p class="mt-6 text-sm text-red-600">{error}</p>
{:else if items.length === 0}<p class="mt-6 text-sm text-gray-500">Der Papierkorb ist leer.</p>
{:else}
<div class="mt-6 space-y-3">
{#each items as item (item.research_id)}
<article class="rounded-lg border border-gray-200 dark:border-gray-700 p-4 flex items-center gap-4">
<div class="flex-1 min-w-0"><p class="font-medium truncate">{item.is_deletion_protected ? '🔒 ' : ''}{item.query}</p><p class="text-xs text-gray-500">Gelöscht: {new Date(item.hidden_at).toLocaleString('de-DE')}</p></div>
<button on:click={() => purge(item)} class="px-3 py-2 rounded bg-red-600 hover:bg-red-700 text-white text-sm">Endgültig löschen</button>
</article>
{/each}
</div>
{/if}
</div>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { page } from '$app/stores';
import { getResearchStatus, getResearchDetail, stopResearch, deleteResearch } from '$lib/research-api';
import { getResearchStatus, getResearchDetail, stopResearch, deleteResearch, setDeletionProtection } from '$lib/research-api';
import { getApiKey } from '$lib/auth';
import { pollingInterval, pollingActive } from '$lib/stores';
import { truncateId, formatDate } from '$lib/utils/formatters';
@@ -116,9 +116,16 @@
async function handleDelete() {
const apiKey = getApiKey();
if (!apiKey) return;
let password: string | undefined;
if (detail?.is_deletion_protected) {
const value = window.prompt('Diese Recherche ist löschgeschützt. Bitte Löschschutz-Passwort eingeben:');
if (value === null) return;
password = value;
}
if (!window.confirm('Recherche aus der normalen Ansicht entfernen? Sie bleibt im Admin-Papierkorb, bis sie dort endgültig gelöscht wird.')) return;
try {
await deleteResearch(apiKey, researchId);
errorBanners.addErrorSuccess('Research gelöscht.');
await deleteResearch(apiKey, researchId, password);
errorBanners.addErrorSuccess('Research wurde in den Papierkorb verschoben.');
window.location.href = '/research/new';
} catch (e) {
const msg = e instanceof Error ? e.message : 'Research konnte nicht gelöscht werden.';
@@ -126,6 +133,32 @@
}
}
async function configureDeletionProtection() {
const apiKey = getApiKey();
if (!apiKey) return;
const password = window.prompt(detail?.is_deletion_protected
? 'Neues Löschschutz-Passwort festlegen (mindestens 8 Zeichen):'
: 'Löschschutz-Passwort festlegen (mindestens 8 Zeichen):');
if (password === null) return;
if (password.length < 8) {
errorBanners.addError('Das Löschschutz-Passwort muss mindestens 8 Zeichen haben.', 'error');
return;
}
let currentPassword: string | undefined;
if (detail?.is_deletion_protected) {
const current = window.prompt('Aktuelles Löschschutz-Passwort eingeben:');
if (current === null) return;
currentPassword = current;
}
try {
await setDeletionProtection(apiKey, researchId, password, currentPassword);
detail = detail ? { ...detail, is_deletion_protected: true } : detail;
errorBanners.addErrorSuccess('Löschschutz wurde aktiviert.');
} catch (e) {
errorBanners.addError(e instanceof Error ? e.message : 'Löschschutz konnte nicht gesetzt werden.', 'error');
}
}
function onShare(token: string) {
window.__shareToken = token;
}
@@ -192,7 +225,8 @@
<div class="flex items-center justify-between gap-4">
<!-- Query + ID -->
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">
<p class="text-sm font-medium text-gray-900 dark:text-white truncate" on:contextmenu|preventDefault={configureDeletionProtection} title="Rechtsklick: Löschschutz festlegen">
{#if detail?.is_deletion_protected}<span title="Löschschutz aktiv">🔒 </span>{/if}
{detail?.query ?? detail?.id ?? researchId}
</p>
<span class="text-xs text-gray-400 font-mono">{truncateId(researchId)}</span>
@@ -235,17 +269,15 @@
on:share={onShare}
on:revoke={onRevoke}
/>
{#if statusText !== 'COMPLETED'}
<button
on:click={handleDelete}
class="p-1.5 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
title="Löschen"
title="In Papierkorb verschieben"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 00-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button>
{/if}
</div>
</div>
</div>