df6d89cb59
Operator-flagged 2026-05-27: walk the whole project for the same shape
as the min-dim Delete-button silent failure (crypto.subtle TypeError
on plain HTTP). FC runs over plain HTTP per the homelab posture;
Secure-Context-gated browser APIs are undefined on the production
origin.
**Audit results across `frontend/src/`:**
crypto.subtle.digest — 2 sites:
- MinDimensionCard (fixed 2026-05-27)
- BulkEditorPanel (THIS FIX)
navigator.clipboard — 1 site, already guarded:
- utils/clipboard.js writeText with execCommand fallback
serviceWorker / mediaDevices / Push / Web USB|HID|Bluetooth|Serial /
cookieStore / queryLocalFonts / WebAuthn / geolocation
— NOT USED, nothing to fix
Extension scripts (background.js) use crypto.subtle but run from
moz-extension:// which IS a Secure Context — left as-is.
**BulkEditorPanel double bug**
The bulk-delete UI on the gallery selection had been broken since
FC-3k shipped, in two ways:
1. `crypto.subtle.digest` swallowed TypeError on plain HTTP — modal
never opened. Same symptom as min-dim.
2. Even on HTTPS, the modal's `kind="images-selection"` produced
`delete-images-selection-<sha8>` while the backend expected
`delete-images-<sha8>`. The two would never match.
Fix:
- Backend `/api/admin/images/bulk-delete` dry-run response now returns
`confirm_token` (the canonical `delete-images-<sha8>` string).
Integration test `test_bulk_delete_dry_run_returns_counts` pinned to
assert the new field.
- DestructiveConfirmModal gains an `expectedTokenOverride` prop. When
set, it bypasses the `${action}-${kind}-${runId}` formula and uses
the explicit string. This decouples the UI label (`kind`) from the
wire-format token (server-provided), so future endpoints can use a
kind-specific label without their kind name leaking into the token.
- BulkEditorPanel passes `:expected-token-override="bulkProjected?.confirm_token"`
— no client-side crypto, no kind-prefix mismatch.
- MinDimensionCard refactored to the same explicit pattern (was
slicing the 8-char suffix off the backend's token and passing it
through `runId`; now passes the full backend token via
`expected-token-override` directly). Cleaner; one source of truth.
**Banked memory**
`feedback_no_secure_context_apis.md` documents the full table of
Secure-Context-gated APIs, which ones FC currently uses, and how each
is handled. Indexed in MEMORY.md. Sites for the audit also listed in
the memory for future drift-checking.
No other Secure-Context-gated APIs found in `frontend/src/`. The same
shape won't recur unless someone adds a new dependency on one — at
which point the banked memory should fire.
139 lines
4.4 KiB
Vue
139 lines
4.4 KiB
Vue
<template>
|
|
<v-dialog
|
|
:model-value="modelValue"
|
|
max-width="520" persistent
|
|
@update:model-value="$emit('update:modelValue', $event)"
|
|
>
|
|
<v-card>
|
|
<v-card-title>
|
|
{{ titleVerb }}
|
|
{{ kindLabel }}<span v-if="runId"> #{{ runId }}</span>
|
|
</v-card-title>
|
|
<v-card-text>
|
|
<v-alert
|
|
:type="alertType"
|
|
variant="tonal" density="compact" class="mb-3"
|
|
>
|
|
<strong>{{ warningText }}</strong>
|
|
<div v-if="description" class="mt-1">{{ description }}</div>
|
|
</v-alert>
|
|
|
|
<div v-if="projectedCounts" class="fc-counts mb-3">
|
|
<div v-for="(v, k) in projectedCounts" :key="k">
|
|
<span class="fc-counts-key">{{ k }}:</span>
|
|
<span class="fc-counts-val">{{ v }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<template v-if="tier === 'C'">
|
|
<div class="text-body-2 mb-2">Type the following to confirm:</div>
|
|
<div class="fc-token mb-3">{{ expectedToken }}</div>
|
|
<v-text-field
|
|
v-model="typed"
|
|
variant="outlined" density="compact" hide-details
|
|
autofocus
|
|
placeholder="paste the token above"
|
|
/>
|
|
</template>
|
|
</v-card-text>
|
|
<v-card-actions>
|
|
<v-spacer />
|
|
<v-btn variant="text" @click="onCancel">Cancel</v-btn>
|
|
<v-btn
|
|
:color="confirmColor"
|
|
variant="flat" rounded="pill"
|
|
:disabled="!canConfirm"
|
|
@click="onConfirm"
|
|
>
|
|
{{ titleVerb }}
|
|
</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-dialog>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, ref, watch } from 'vue'
|
|
|
|
const props = defineProps({
|
|
modelValue: { type: Boolean, required: true },
|
|
action: { type: String, required: true }, // 'restore' | 'delete'
|
|
kind: { type: String, required: true }, // 'db' | 'images' | 'artist' | 'tag' | 'images-selection' | 'audit' | 'min-dim'
|
|
runId: { type: [Number, String], default: '' }, // numeric id or sha8 string
|
|
description: { type: String, default: '' },
|
|
tier: { type: String, default: 'C' }, // 'B' | 'C'
|
|
projectedCounts: { type: Object, default: null },
|
|
// Override the `${action}-${kind}-${runId}` token formula. Use when
|
|
// the backend computes the canonical confirm token (e.g. bulk-delete
|
|
// and min-dim cleanup both return `confirm_token` from their dry-run
|
|
// endpoints) and the UI's kind/runId would otherwise produce a
|
|
// mismatched string. Operator-flagged 2026-05-27 after the
|
|
// BulkEditor's kind="images-selection" produced
|
|
// `delete-images-selection-<sha8>` while the backend expected
|
|
// `delete-images-<sha8>`.
|
|
expectedTokenOverride: { type: String, default: '' },
|
|
})
|
|
const emit = defineEmits(['update:modelValue', 'confirm'])
|
|
|
|
const typed = ref('')
|
|
const expectedToken = computed(
|
|
() => props.expectedTokenOverride
|
|
|| `${props.action}-${props.kind}-${props.runId}`,
|
|
)
|
|
const titleVerb = computed(
|
|
() => props.action === 'restore' ? 'Restore' : 'Delete',
|
|
)
|
|
const kindLabel = computed(() => ({
|
|
db: 'database backup',
|
|
images: 'images backup',
|
|
artist: 'artist',
|
|
tag: 'tag',
|
|
'images-selection': 'image selection',
|
|
}[props.kind] || props.kind))
|
|
const alertType = computed(
|
|
() => props.action === 'restore' ? 'warning' : 'error',
|
|
)
|
|
const confirmColor = computed(
|
|
() => props.action === 'restore' ? 'warning' : 'error',
|
|
)
|
|
const warningText = computed(() => (
|
|
props.action === 'restore'
|
|
? 'This replaces current state with the backup. There is no undo.'
|
|
: 'This permanently deletes the listed items. Cannot be recovered.'
|
|
))
|
|
const canConfirm = computed(
|
|
() => props.tier === 'B'
|
|
? true
|
|
: typed.value === expectedToken.value,
|
|
)
|
|
|
|
watch(() => props.modelValue, (open) => {
|
|
if (open) typed.value = ''
|
|
})
|
|
|
|
function onCancel() {
|
|
emit('update:modelValue', false)
|
|
}
|
|
function onConfirm() {
|
|
emit('confirm', expectedToken.value)
|
|
emit('update:modelValue', false)
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.fc-token {
|
|
font-family: 'JetBrains Mono', monospace;
|
|
background: rgb(var(--v-theme-surface-light));
|
|
padding: 6px 10px; border-radius: 4px;
|
|
font-size: 14px; word-break: break-all;
|
|
}
|
|
.fc-counts {
|
|
display: grid; grid-template-columns: max-content auto;
|
|
gap: 4px 12px;
|
|
font-size: 13px;
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
}
|
|
.fc-counts-key { font-weight: 500; text-transform: capitalize; }
|
|
.fc-counts-val { font-variant-numeric: tabular-nums; }
|
|
</style>
|