refactor(settings): remove enableDestructiveDeleteActions runtime flag and related UI

Eliminate the enableDestructiveDeleteActions feature flag from runtime
configuration, admin panel, environment docs, and user settings modal.
Remove all references, toggles, and documentation for this flag. This
simplifies the runtime config and user interface by consolidating
destructive actions under account deletion only.

BREAKING CHANGE: The enableDestructiveDeleteActions runtime flag is no longer supported. Any configuration or code depending on this flag must be updated.
This commit is contained in:
Richard R 2026-06-01 20:38:54 -06:00
parent e295ee4103
commit 28f8d50c05
15 changed files with 151 additions and 91 deletions

View file

@ -87,4 +87,4 @@ S3_BUCKET=
# (Optional) v4 JSON seed for first-boot runtime config + shared providers.
# If both are set, RUNTIME_SEED_JSON_PATH is used.
# RUNTIME_SEED_JSON_PATH=/absolute/path/to/openreader-seed.json
# RUNTIME_SEED_JSON={"version":1,"runtimeConfig":{"enableUserSignups":true,"restrictUserApiKeys":true,"defaultTtsProvider":"custom-openai","enableTtsProvidersTab":true,"enableAudiobookExport":true,"enableDocxConversion":true,"enableDestructiveDeleteActions":true,"showAllProviderModels":true,"disableTtsRateLimit":true,"ttsDailyLimitAnonymous":50000,"ttsDailyLimitAuthenticated":500000,"ttsIpDailyLimitAnonymous":100000,"ttsIpDailyLimitAuthenticated":1000000,"ttsCacheMaxSizeBytes":268435456,"ttsCacheTtlMs":1800000,"ttsUpstreamMaxRetries":2,"ttsUpstreamTimeoutMs":285000,"disableComputeRateLimit":true,"computeParseBurstMax":8,"computeParseBurstWindowSec":60,"computeParseSustainedMax":24,"computeParseSustainedWindowSec":600,"maxUploadMb":200,"changelogFeedUrl":"https://docs.openreader.richardr.dev/changelog/manifest.json"},"providers":[{"slug":"default-openai","displayName":"Default (seeded)","providerType":"custom-openai","baseUrl":"http://localhost:8880/v1","apiKey":"api_key_optional","defaultModel":"kokoro","enabled":true}]}
# RUNTIME_SEED_JSON={"version":1,"runtimeConfig":{"enableUserSignups":true,"restrictUserApiKeys":true,"defaultTtsProvider":"custom-openai","enableTtsProvidersTab":true,"enableAudiobookExport":true,"enableDocxConversion":true,"showAllProviderModels":true,"disableTtsRateLimit":true,"ttsDailyLimitAnonymous":50000,"ttsDailyLimitAuthenticated":500000,"ttsIpDailyLimitAnonymous":100000,"ttsIpDailyLimitAuthenticated":1000000,"ttsCacheMaxSizeBytes":268435456,"ttsCacheTtlMs":1800000,"ttsUpstreamMaxRetries":2,"ttsUpstreamTimeoutMs":285000,"disableComputeRateLimit":true,"computeParseBurstMax":8,"computeParseBurstWindowSec":60,"computeParseSustainedMax":24,"computeParseSustainedWindowSec":600,"maxUploadMb":200,"changelogFeedUrl":"https://docs.openreader.richardr.dev/changelog/manifest.json"},"providers":[{"slug":"default-openai","displayName":"Default (seeded)","providerType":"custom-openai","baseUrl":"http://localhost:8880/v1","apiKey":"api_key_optional","defaultModel":"kokoro","enabled":true}]}

View file

@ -76,7 +76,6 @@ Runtime-editable settings, one row per key:
| `showAllProviderModels` | When `false`, users are restricted to each provider's default model (shared provider `defaultModel` or built-in provider default). |
| `enableAudiobookExport` | Show the audiobook export entry points on PDF/EPUB pages. |
| `enableDocxConversion` | Accept .docx uploads (converted to PDF server-side). |
| `enableDestructiveDeleteActions` | Show "Delete all data" buttons in the Documents tab (auth-disabled mode). |
Word-by-word highlighting and PDF layout parsing capability are controlled by compute-worker server env configuration, not an admin runtime flag.

View file

@ -78,7 +78,6 @@ After the first successful deploy and admin login, open **Settings → Admin** a
- **Shared providers**: create/edit your provider key(s) here (encrypted at rest).
- **Site features**:
- `enableDocxConversion=false` on Vercel (`soffice` unavailable).
- `enableDestructiveDeleteActions=false` for safer public deployments.
- `enableTtsProvidersTab=false` if you want shared-provider-only UX.
- `enableUserSignups=true` unless you explicitly want an invite-only deployment.
- `restrictUserApiKeys=true` to block user BYOK through the hosted server.

View file

@ -414,7 +414,6 @@ Example:
"enableTtsProvidersTab": true,
"enableAudiobookExport": true,
"enableDocxConversion": true,
"enableDestructiveDeleteActions": true,
"showAllProviderModels": true,
"disableTtsRateLimit": true,
"ttsDailyLimitAnonymous": 50000,

View file

@ -5,7 +5,10 @@ import { db } from '@/db';
import { documents } from '@/db/schema';
import { requireAuthContext } from '@/lib/server/auth/auth';
import { createOrReusePdfWorkerOperation } from '@/lib/server/compute/worker-op-create';
import { snapshotFromWorkerState } from '@/lib/server/compute/worker-parse-state';
import {
documentParseStateFromWorkerState,
snapshotFromWorkerState,
} from '@/lib/server/compute/worker-parse-state';
import { fetchWorkerOperationState } from '@/lib/server/compute/worker-op-state';
import {
documentKey,
@ -101,8 +104,14 @@ async function finalizeFromWorkerState(input: {
logger: ServerLogger;
}): Promise<NextResponse> {
const snapshot = snapshotFromWorkerState(input.workerState);
const workerParseState = documentParseStateFromWorkerState(input.workerState);
if (snapshot.parseStatus === 'pending' || snapshot.parseStatus === 'running') {
await writeParseRowState({
documentId: input.row.id,
userId: input.row.userId,
parseState: stringifyDocumentParseState(workerParseState),
});
return NextResponse.json({
parseStatus: snapshot.parseStatus,
parseProgress: snapshot.parseProgress,
@ -114,12 +123,7 @@ async function finalizeFromWorkerState(input: {
await writeParseRowState({
documentId: input.row.id,
userId: input.row.userId,
parseState: stringifyDocumentParseState({
status: 'failed',
progress: null,
updatedAt: Date.now(),
error: input.workerState.error?.message ?? 'Worker parse failed',
}),
parseState: stringifyDocumentParseState(workerParseState),
});
return NextResponse.json({
@ -152,11 +156,7 @@ async function finalizeFromWorkerState(input: {
await writeParseRowState({
documentId: input.row.id,
userId: input.row.userId,
parseState: stringifyDocumentParseState({
status: 'ready',
progress: null,
updatedAt: Date.now(),
}),
parseState: stringifyDocumentParseState(workerParseState),
parsedJsonKey,
});
@ -278,6 +278,11 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
});
await recordJobEvent(authCtxOrRes.userId, 'pdf_layout', created.opId, rateConfig);
const snapshot = snapshotFromWorkerState(created);
await writeParseRowState({
documentId: row.id,
userId: row.userId,
parseState: stringifyDocumentParseState(documentParseStateFromWorkerState(created)),
});
return NextResponse.json({
parseStatus: snapshot.parseStatus,
parseProgress: snapshot.parseProgress,
@ -409,6 +414,11 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
await recordJobEvent(authCtxOrRes.userId, 'pdf_layout', created.opId, rateConfig);
const snapshot = snapshotFromWorkerState(created);
await writeParseRowState({
documentId: row.id,
userId: row.userId,
parseState: stringifyDocumentParseState(documentParseStateFromWorkerState(created)),
});
return NextResponse.json({
parseStatus: snapshot.parseStatus,

View file

@ -22,7 +22,7 @@ import { useAuthSession } from '@/hooks/useAuthSession';
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
import { useRouter } from 'next/navigation';
import { showPrivacyModal } from '@/components/PrivacyModal';
import { deleteDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents';
import { mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents';
import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/cache/documents';
import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews';
import { resolveTtsSettingsViewModel } from '@/lib/client/settings/tts-settings';
@ -222,7 +222,6 @@ export function SettingsModal({
onOpenChange: (open: boolean) => void;
}) {
const runtimeConfig = useRuntimeConfig();
const enableDestructiveDelete = runtimeConfig.enableDestructiveDeleteActions;
const showAllProviderModels = runtimeConfig.showAllProviderModels;
const enableTTSProvidersTab = runtimeConfig.enableTtsProvidersTab;
const restrictUserApiKeys = runtimeConfig.restrictUserApiKeys;
@ -258,7 +257,6 @@ export function SettingsModal({
const [showProgress, setShowProgress] = useState(false);
const [statusMessage, setStatusMessage] = useState('');
const [abortController, setAbortController] = useState<AbortController | null>(null);
const [showDeleteDocsConfirm, setShowDeleteDocsConfirm] = useState(false);
const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false);
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
const { baseUrl: authBaseUrl } = useAuthConfig();
@ -441,17 +439,6 @@ export function SettingsModal({
}
};
const handleDeleteDocs = async () => {
try {
await deleteDocuments();
await refreshDocuments().catch(() => { });
} catch (error) {
console.error('Delete failed:', error);
} finally {
setShowDeleteDocsConfirm(false);
}
};
const handleSignOut = async () => {
const client = getAuthClient(authBaseUrl);
await client.signOut();
@ -1189,21 +1176,19 @@ export function SettingsModal({
Disconnect account
</Button>
{enableDestructiveDelete && (
<div className="pt-4 mt-4 border-t border-line-soft">
<label className="block text-sm font-medium text-danger mb-2">Danger Zone</label>
<Button
onClick={() => setShowDeleteAccountConfirm(true)}
variant="danger"
size="md"
>
Delete Account
</Button>
<p className="text-xs text-soft mt-2">
Permanently deletes your account and all data.
</p>
</div>
)}
<div className="pt-4 mt-4 border-t border-line-soft">
<label className="block text-sm font-medium text-danger mb-2">Danger Zone</label>
<Button
onClick={() => setShowDeleteAccountConfirm(true)}
variant="danger"
size="md"
>
Delete Account
</Button>
<p className="text-xs text-soft mt-2">
Permanently deletes your account and all data.
</p>
</div>
</>
) : (
<div className="pt-2 border-t border-line-soft">
@ -1246,16 +1231,6 @@ export function SettingsModal({
)}
</ModalFrame>
<ConfirmDialog
isOpen={showDeleteDocsConfirm}
onClose={() => setShowDeleteDocsConfirm(false)}
onConfirm={handleDeleteDocs}
title="Delete All Data"
message="Are you sure you want to delete all documents from the server? This action cannot be undone."
confirmText="Delete"
isDangerous={true}
/>
<ConfirmDialog
isOpen={showDeleteAccountConfirm}
onClose={() => setShowDeleteAccountConfirm(false)}

View file

@ -495,14 +495,6 @@ export function AdminFeaturesPanel() {
right={renderSource('enableDocxConversion')}
variant="flat"
/>
<ToggleRow
label="Destructive delete buttons"
description='Show "Delete all data" actions (auth-off mode).'
checked={Boolean(draft.enableDestructiveDeleteActions)}
onChange={(checked) => updateDraft('enableDestructiveDeleteActions', checked)}
right={renderSource('enableDestructiveDeleteActions')}
variant="flat"
/>
</Section>
<Section

View file

@ -20,7 +20,6 @@ export interface RuntimeConfig {
enableTtsProvidersTab: boolean;
enableAudiobookExport: boolean;
enableDocxConversion: boolean;
enableDestructiveDeleteActions: boolean;
showAllProviderModels: boolean;
disableTtsRateLimit: boolean;
ttsDailyLimitAnonymous: number;
@ -43,7 +42,6 @@ const RUNTIME_DEFAULTS: RuntimeConfig = {
enableTtsProvidersTab: true,
enableAudiobookExport: true,
enableDocxConversion: true,
enableDestructiveDeleteActions: true,
showAllProviderModels: true,
disableTtsRateLimit: true,
ttsDailyLimitAnonymous: 50_000,

View file

@ -71,7 +71,6 @@ export const RUNTIME_CONFIG_SCHEMA = {
enableTtsProvidersTab: booleanFlag(true),
enableAudiobookExport: booleanFlag(true),
enableDocxConversion: booleanFlag(true),
enableDestructiveDeleteActions: booleanFlag(true),
showAllProviderModels: runtimeBoolean(true),
disableTtsRateLimit: booleanFlag(true),
ttsDailyLimitAnonymous: positiveIntValue(50_000),

View file

@ -1,5 +1,6 @@
import type { PdfLayoutJobResult, WorkerOperationState } from '@openreader/compute-core/api-contracts';
import type { PdfParseProgress, PdfParseStatus } from '@/types/parsed-pdf';
import type { DocumentParseState } from '@/lib/server/documents/parse-state';
export function mapWorkerStatusToParseStatus(status: WorkerOperationState['status']): PdfParseStatus {
switch (status) {
@ -26,6 +27,23 @@ export function snapshotFromWorkerState(
};
}
export function documentParseStateFromWorkerState(
state: WorkerOperationState<PdfLayoutJobResult>,
nowMs = Date.now(),
): DocumentParseState {
const { parseStatus, parseProgress } = snapshotFromWorkerState(state);
return {
status: parseStatus,
progress: parseStatus === 'pending' || parseStatus === 'running'
? parseProgress
: null,
updatedAt: nowMs,
...(typeof state.opId === 'string' && state.opId.trim() ? { opId: state.opId } : {}),
...(typeof state.jobId === 'string' && state.jobId.trim() ? { jobId: state.jobId } : {}),
...(parseStatus === 'failed' && state.error?.message ? { error: state.error.message } : {}),
};
}
export function mergeNonReadyParseSnapshot(input: {
parseStatus: PdfParseStatus;
parseProgress: PdfParseProgress | null;

View file

@ -22,6 +22,8 @@ export async function healStaleDocumentParseState(input: {
progress: null,
updatedAt: Date.now(),
error: `Parse state stale for more than ${staleMs}ms; marked failed for retry`,
...(input.state.opId ? { opId: input.state.opId } : {}),
...(input.state.jobId ? { jobId: input.state.jobId } : {}),
}));
await db

View file

@ -255,9 +255,8 @@ export async function setupTest(page: Page, testInfo?: TestInfo) {
}
});
// If auth is enabled, establish an anonymous session BEFORE navigation.
// This keeps each test self-contained (no shared storageState) while ensuring
// server routes that require auth don't intermittently 401 during app startup.
// If we explicitly choose to bootstrap anonymous sessions for a test run,
// do it before navigation so protected startup routes do not intermittently 401.
// await ensureAnonymousSession(page);
// Navigate to the protected app home before each test
@ -412,22 +411,6 @@ export async function openSettingsDocumentsTab(page: Page) {
await settingsDialog.getByRole('button', { name: /^Documents$/ }).click();
}
// Delete all local documents through Settings and close dialogs
export async function deleteAllLocalDocuments(page: Page) {
await openSettingsDocumentsTab(page);
await page.getByRole('button', { name: /Delete all data/i }).click();
const heading = page.getByRole('heading', { name: /Delete All Data/i });
await expect(heading).toBeVisible({ timeout: 10000 });
const confirmBtn = heading.locator('xpath=ancestor::*[@role="dialog"][1]//button[normalize-space()="Delete"]');
await confirmBtn.click();
// Close any remaining modal layers
await page.keyboard.press('Escape');
await page.keyboard.press('Escape');
}
// Open the Voices dropdown from the TTS bar and return the button locator
export async function openVoicesMenu(page: Page) {
const ttsbar = page.locator('[data-app-ttsbar]');

View file

@ -2,8 +2,6 @@ import { expect, test } from '@playwright/test';
import { setupTest, uploadAndDisplay } from './helpers';
const AUTH_ENABLED = Boolean(process.env.AUTH_SECRET && process.env.BASE_URL);
test.describe('Landing and app routing', () => {
test('public landing renders without anonymous auth bootstrap call', async ({ page }) => {
let anonymousSignInCalls = 0;
@ -26,8 +24,6 @@ test.describe('Landing and app routing', () => {
});
test('existing authenticated session visiting / redirects to /app', async ({ page }) => {
test.skip(!AUTH_ENABLED);
await page.goto('/app');
await page.waitForLoadState('networkidle');
await page.waitForSelector('.fixed.inset-0.bg-base.z-50', { state: 'detached', timeout: 15000 }).catch(() => {});
@ -46,7 +42,7 @@ test.describe('Landing and app routing', () => {
});
test('protected app routes redirect to /signin when anonymous auth is disabled', async ({ page }) => {
test.skip(!AUTH_ENABLED || process.env.USE_ANONYMOUS_AUTH_SESSIONS !== 'false');
test.skip(process.env.USE_ANONYMOUS_AUTH_SESSIONS !== 'false');
await page.goto('/app');
await expect(page).toHaveURL(/\/signin$/);

View file

@ -234,7 +234,6 @@ describe('runtime config JSON seeding', () => {
enableTtsProvidersTab: false,
enableAudiobookExport: false,
enableDocxConversion: false,
enableDestructiveDeleteActions: false,
showAllProviderModels: false,
disableTtsRateLimit: false,
ttsDailyLimitAnonymous: 12345,

View file

@ -0,0 +1,91 @@
import { describe, expect, test } from 'vitest';
import type { PdfLayoutJobResult, WorkerOperationState } from '@openreader/compute-core/api-contracts';
import {
documentParseStateFromWorkerState,
snapshotFromWorkerState,
} from '../../src/lib/server/compute/worker-parse-state';
function makeWorkerState(
overrides: Partial<WorkerOperationState<PdfLayoutJobResult>>,
): WorkerOperationState<PdfLayoutJobResult> {
return {
opId: 'op-123',
opKey: 'pdf_layout|v1|doc-1',
kind: 'pdf_layout',
jobId: 'job-123',
status: 'queued',
queuedAt: 1_700_000_000_000,
updatedAt: 1_700_000_000_000,
...overrides,
};
}
describe('worker parse state mapping', () => {
test('maps queued worker state to pending parse state with op identifiers', () => {
const workerState = makeWorkerState({
status: 'queued',
progress: {
totalPages: 500,
pagesParsed: 0,
currentPage: 1,
phase: 'infer',
},
});
expect(snapshotFromWorkerState(workerState)).toEqual({
parseStatus: 'pending',
parseProgress: null,
});
expect(documentParseStateFromWorkerState(workerState, 1234)).toEqual({
status: 'pending',
progress: null,
updatedAt: 1234,
opId: 'op-123',
jobId: 'job-123',
});
});
test('maps running worker state to running parse state with progress', () => {
const workerState = makeWorkerState({
status: 'running',
progress: {
totalPages: 500,
pagesParsed: 120,
currentPage: 121,
phase: 'infer',
},
});
expect(documentParseStateFromWorkerState(workerState, 5678)).toEqual({
status: 'running',
progress: {
totalPages: 500,
pagesParsed: 120,
currentPage: 121,
phase: 'infer',
},
updatedAt: 5678,
opId: 'op-123',
jobId: 'job-123',
});
});
test('maps failed worker state to failed parse state and preserves the worker error', () => {
const workerState = makeWorkerState({
status: 'failed',
error: {
code: 'PDF_PARSE_FAILED',
message: 'layout model crashed',
},
});
expect(documentParseStateFromWorkerState(workerState, 9999)).toEqual({
status: 'failed',
progress: null,
updatedAt: 9999,
opId: 'op-123',
jobId: 'job-123',
error: 'layout model crashed',
});
});
});