Add research deletion protection interface
This commit is contained in:
@@ -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}`);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface ResearchSummary {
|
||||
status: string;
|
||||
created_at: string;
|
||||
completed_at?: string;
|
||||
is_deletion_protected?: boolean;
|
||||
}
|
||||
|
||||
export interface SourceInfo {
|
||||
|
||||
57
src/routes/admin/deleted/+page.svelte
Normal file
57
src/routes/admin/deleted/+page.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user