275 lines
9.7 KiB
Vue
275 lines
9.7 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>
|
|
<div v-if="loading" class="loading-overlay">
|
|
<div class="loading-spinner"></div>
|
|
<span>Loading…</span>
|
|
</div>
|
|
<template v-else-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="sm" @click="openEdit(e)">Edit</NychButton>
|
|
<NychButton size="sm" variant="danger" :loading="deletingId === e.id" @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:open="showEdit">
|
|
<NychDialogContent class="w-[min(640px,95vw)] sm:max-w-[min(640px,95vw)]">
|
|
<NychDialogHeader><NychDialogTitle>Edit Blacklist Entry</NychDialogTitle></NychDialogHeader>
|
|
<form @submit.prevent="submitEdit" class="dialog-form">
|
|
<div class="field">
|
|
<label>Pattern</label>
|
|
<NychInput v-model="editForm.pattern" class="w-full" />
|
|
</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>
|
|
<NychInput v-model="editForm.reason" class="w-full" />
|
|
</div>
|
|
<div class="field">
|
|
<label>Bypass permission mask <span class="optional">(optional)</span></label>
|
|
<NychInput v-model="editForm.bypass_mask" placeholder="32" class="w-full" />
|
|
</div>
|
|
<div class="field">
|
|
<label>Status</label>
|
|
<NychSelect v-model="editForm.active" class="w-full">
|
|
<NychSelectTrigger class="w-full"><NychSelectValue>{{ editForm.active === 'true' ? 'Active' : 'Disabled' }}</NychSelectValue></NychSelectTrigger>
|
|
<NychSelectContent class="z-[60]">
|
|
<NychSelectItem value="true">Active</NychSelectItem>
|
|
<NychSelectItem value="false">Disabled</NychSelectItem>
|
|
</NychSelectContent>
|
|
</NychSelect>
|
|
</div>
|
|
<NychButton type="submit" class="w-full" :loading="saving">Save Changes</NychButton>
|
|
</form>
|
|
</NychDialogContent>
|
|
</NychDialog>
|
|
|
|
<NychDialog v-model:open="showCreate">
|
|
<NychDialogContent class="w-[min(640px,95vw)] sm:max-w-[min(640px,95vw)]">
|
|
<NychDialogHeader><NychDialogTitle>New Blacklist Entry</NychDialogTitle></NychDialogHeader>
|
|
<form @submit.prevent="submitCreate" class="dialog-form">
|
|
<div class="field">
|
|
<label>Pattern</label>
|
|
<NychInput v-model="form.pattern" placeholder="/api/sensitive/**" class="w-full" />
|
|
<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>
|
|
<NychInput v-model="form.reason" placeholder="Why is this route blocked?" class="w-full" />
|
|
</div>
|
|
<div class="field">
|
|
<label>Bypass permission mask <span class="optional">(optional)</span></label>
|
|
<NychInput v-model="form.bypass_mask" placeholder="32" class="w-full" />
|
|
<p class="hint">Permission bit that allows callers to bypass this rule. Leave blank to block everyone.</p>
|
|
</div>
|
|
<NychButton type="submit" class="w-full" :loading="creating">Add to Blacklist</NychButton>
|
|
</form>
|
|
</NychDialogContent>
|
|
</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 loading = ref(false)
|
|
|
|
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']
|
|
|
|
async function load() {
|
|
loading.value = true
|
|
try {
|
|
const res = await fetch('/api/blacklist', { headers: auth.authHeaders() })
|
|
entries.value = await res.json()
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
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) : []
|
|
}
|
|
|
|
const creating = ref(false)
|
|
|
|
async function submitCreate() {
|
|
creating.value = true
|
|
try {
|
|
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: '' }
|
|
await load()
|
|
} finally {
|
|
creating.value = false
|
|
}
|
|
}
|
|
|
|
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: String(e.active),
|
|
}
|
|
showEdit.value = true
|
|
}
|
|
|
|
const saving = ref(false)
|
|
|
|
async function submitEdit() {
|
|
saving.value = true
|
|
try {
|
|
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 === 'true',
|
|
}),
|
|
})
|
|
showEdit.value = false
|
|
await load()
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
const deletingId = ref<number | null>(null)
|
|
|
|
async function deleteEntry(id: number) {
|
|
if (!confirm('Delete this blacklist entry?')) return
|
|
deletingId.value = id
|
|
try {
|
|
await fetch(`/api/blacklist/${id}`, { method: 'DELETE', headers: auth.authHeaders() })
|
|
await load()
|
|
} finally {
|
|
deletingId.value = null
|
|
}
|
|
}
|
|
|
|
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>
|