Mercury/ui/src/views/admin/Blacklist.vue
Matthew L McPeak 05cb37730d
All checks were successful
ci / build-ui (push) Successful in 14s
ci / test (push) Successful in 3m1s
ci / publish (push) Successful in 2m38s
Initial Commit
2026-06-17 16:21:16 -04:00

232 lines
8.4 KiB
Vue

<template>
<div>
<div class="page-header">
<div class="page-title">
<h2>Route Blacklist</h2>
<span class="subtitle">Glob patterns blocking API routes matched against path and HTTP method</span>
</div>
<NychButton @click="showCreate = true">+ Add Pattern</NychButton>
</div>
<div class="table-card">
<div class="table-card-header">
<span class="count">{{ entries.length }} {{ entries.length === 1 ? 'entry' : 'entries' }}</span>
</div>
<template v-if="entries.length"><div class="table-scroll"><table class="data-table">
<thead>
<tr>
<th>Pattern</th>
<th>Method</th>
<th>Status</th>
<th>Reason</th>
<th>Bypass mask</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="e in entries" :key="e.id">
<td><code>{{ e.pattern }}</code></td>
<td>
<div class="method-cell">
<template v-if="e.method">
<span v-for="m in e.method.split(',')" :key="m" class="method-badge">{{ m.trim() }}</span>
</template>
<span v-else class="dim">ALL</span>
</div>
</td>
<td>
<span class="status-badge" :class="e.active ? 'status-active' : 'status-inactive'">
{{ e.active ? 'Active' : 'Disabled' }}
</span>
</td>
<td class="reason-cell">{{ e.reason ?? '—' }}</td>
<td><span v-if="e.bypass_mask" class="bit-badge">{{ e.bypass_mask }}</span><span v-else class="dim"></span></td>
<td class="actions-cell">
<NychButton size="small" @click="openEdit(e)">Edit</NychButton>
<NychButton size="small" severity="danger" @click="deleteEntry(e.id)">Delete</NychButton>
</td>
</tr>
</tbody>
</table></div></template>
<div class="empty-state" v-else>
<span class="empty-icon"></span>
<span class="empty-label">Blacklist is empty</span>
<span class="empty-hint">All routes are currently open. Add a pattern to block access.</span>
</div>
</div>
<NychDialog v-model:visible="showEdit" header="Edit Blacklist Entry" :modal="true" :draggable="false" style="width: min(640px, 95vw)">
<form @submit.prevent="submitEdit" class="dialog-form">
<div class="field">
<label>Pattern</label>
<NychInputText v-model="editForm.pattern" fluid />
</div>
<div class="field">
<label>HTTP Methods <span class="optional">(none = all methods)</span></label>
<MethodSelect v-model="editForm.methods" :options="HTTP_METHODS" placeholder="All methods" />
</div>
<div class="field">
<label>Reason <span class="optional">(optional)</span></label>
<NychInputText v-model="editForm.reason" fluid />
</div>
<div class="field">
<label>Bypass permission mask <span class="optional">(optional)</span></label>
<NychInputText v-model="editForm.bypass_mask" placeholder="32" fluid />
</div>
<div class="field">
<label>Status</label>
<NychSelect v-model="editForm.active" :options="[{ label: 'Active', value: true }, { label: 'Disabled', value: false }]" optionLabel="label" optionValue="value" fluid />
</div>
<NychButton type="submit" fluid>Save Changes</NychButton>
</form>
</NychDialog>
<NychDialog v-model:visible="showCreate" header="New Blacklist Entry" :modal="true" :draggable="false" style="width: min(640px, 95vw)">
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
<label>Pattern</label>
<NychInputText v-model="form.pattern" placeholder="/api/sensitive/**" fluid />
<p class="hint">Use <code>*</code> for one segment, <code>**</code> for any depth.</p>
</div>
<div class="field">
<label>HTTP Methods <span class="optional">(none = all methods)</span></label>
<MethodSelect v-model="form.methods" :options="HTTP_METHODS" placeholder="All methods" />
</div>
<div class="field">
<label>Reason <span class="optional">(optional)</span></label>
<NychInputText v-model="form.reason" placeholder="Why is this route blocked?" fluid />
</div>
<div class="field">
<label>Bypass permission mask <span class="optional">(optional)</span></label>
<NychInputText v-model="form.bypass_mask" placeholder="32" fluid />
<p class="hint">Permission bit that allows callers to bypass this rule. Leave blank to block everyone.</p>
</div>
<NychButton type="submit" fluid>Add to Blacklist</NychButton>
</form>
</NychDialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import MethodSelect from './MethodSelect.vue'
import { useAuthStore } from '../../stores/auth'
const auth = useAuthStore()
const entries = ref<any[]>([])
const showCreate = ref(false)
const showEdit = ref(false)
const editId = ref<number | null>(null)
const editForm = ref({ pattern: '', methods: [] as string[], reason: '', bypass_mask: '', active: true })
const form = ref({ pattern: '', methods: [] as string[], reason: '', bypass_mask: '' })
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']
async function load() {
const res = await fetch('/api/blacklist', { headers: auth.authHeaders() })
entries.value = await res.json()
}
function methodsToString(methods: string[]): string | null {
return methods.length ? methods.join(',') : null
}
function stringToMethods(s: string | null | undefined): string[] {
return s ? s.split(',').map(m => m.trim()).filter(Boolean) : []
}
async function submitCreate() {
await fetch('/api/blacklist', {
method: 'POST',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({
pattern: form.value.pattern,
method: methodsToString(form.value.methods),
reason: form.value.reason || null,
bypass_mask: form.value.bypass_mask || null,
active: true,
}),
})
showCreate.value = false
form.value = { pattern: '', methods: [], reason: '', bypass_mask: '' }
load()
}
function openEdit(e: any) {
editId.value = e.id
editForm.value = {
pattern: e.pattern,
methods: stringToMethods(e.method),
reason: e.reason ?? '',
bypass_mask: e.bypass_mask ?? '',
active: e.active,
}
showEdit.value = true
}
async function submitEdit() {
await fetch(`/api/blacklist/${editId.value}`, {
method: 'PUT',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({
pattern: editForm.value.pattern,
method: methodsToString(editForm.value.methods),
reason: editForm.value.reason || null,
bypass_mask: editForm.value.bypass_mask || null,
active: editForm.value.active,
}),
})
showEdit.value = false
load()
}
async function deleteEntry(id: number) {
if (!confirm('Delete this blacklist entry?')) return
await fetch(`/api/blacklist/${id}`, { method: 'DELETE', headers: auth.authHeaders() })
load()
}
onMounted(load)
</script>
<style scoped>
.reason-cell { color: var(--text-muted); font-size: 0.85rem; }
.dim { color: var(--text-dim); font-size: 0.8rem; font-family: var(--font-mono); }
.method-cell { display: flex; gap: 0.25rem; flex-wrap: wrap; align-items: center; }
.bit-badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-family: var(--font-mono);
font-size: 0.78rem;
background: var(--surface-2);
border: 1px solid var(--border-lo);
color: var(--text-label);
}
.method-badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: 600;
background: var(--surface-2);
border: 1px solid var(--border-lo);
color: var(--text-label);
}
.status-badge {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 20px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.status-active { background: var(--danger-subtle); color: var(--danger); border: 1px solid var(--danger-border); }
.status-inactive { background: var(--surface-2); color: var(--text-dim); border: 1px solid var(--border-lo); }
</style>