diff --git a/.gitignore b/.gitignore index 505a3b1..b04525c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ wheels/ # Virtual environments .venv + +# Node-generated files +node_modules/ +npm-debug.log* diff --git a/app/api/bindings.py b/app/api/bindings.py index 10dbcfa..caa4dca 100644 --- a/app/api/bindings.py +++ b/app/api/bindings.py @@ -36,6 +36,21 @@ def to_binding_item(binding: TokenBinding, binding_service: BindingService) -> B ) +def apply_binding_filters( + statement, + token_suffix: str | None, + ip: str | None, + status_filter: int | None, +): + if token_suffix: + statement = statement.where(TokenBinding.token_display.ilike(f"%{token_suffix}%")) + if ip: + statement = statement.where(cast(TokenBinding.bound_ip, String).ilike(f"%{ip}%")) + if status_filter in {STATUS_ACTIVE, STATUS_BANNED}: + statement = statement.where(TokenBinding.status == status_filter) + return statement + + async def get_binding_or_404(session: AsyncSession, binding_id: int) -> TokenBinding: binding = await session.get(TokenBinding, binding_id) if binding is None: @@ -54,6 +69,21 @@ def log_admin_action(request: Request, settings: Settings, action: str, binding_ ) +async def commit_binding_cache(binding: TokenBinding, binding_service: BindingService) -> None: + await binding_service.sync_binding_cache(binding.token_hash, str(binding.bound_ip), binding.status) + + +async def update_binding_status( + session: AsyncSession, + binding: TokenBinding, + status_code: int, + binding_service: BindingService, +) -> None: + binding.status = status_code + await session.commit() + await commit_binding_cache(binding, binding_service) + + @router.get("", response_model=BindingListResponse) async def list_bindings( page: int = Query(default=1, ge=1), @@ -64,13 +94,7 @@ async def list_bindings( session: AsyncSession = Depends(get_db_session), binding_service: BindingService = Depends(get_binding_service), ) -> BindingListResponse: - statement = select(TokenBinding) - if token_suffix: - statement = statement.where(TokenBinding.token_display.ilike(f"%{token_suffix}%")) - if ip: - statement = statement.where(cast(TokenBinding.bound_ip, String).ilike(f"%{ip}%")) - if status_filter in {STATUS_ACTIVE, STATUS_BANNED}: - statement = statement.where(TokenBinding.status == status_filter) + statement = apply_binding_filters(select(TokenBinding), token_suffix, ip, status_filter) total_result = await session.execute(select(func.count()).select_from(statement.subquery())) total = int(total_result.scalar_one()) @@ -116,7 +140,7 @@ async def update_bound_ip( binding = await get_binding_or_404(session, payload.id) binding.bound_ip = payload.bound_ip await session.commit() - await binding_service.sync_binding_cache(binding.token_hash, str(binding.bound_ip), binding.status) + await commit_binding_cache(binding, binding_service) log_admin_action(request, settings, "update_ip", payload.id) return {"success": True} @@ -130,9 +154,7 @@ async def ban_token( binding_service: BindingService = Depends(get_binding_service), ): binding = await get_binding_or_404(session, payload.id) - binding.status = STATUS_BANNED - await session.commit() - await binding_service.sync_binding_cache(binding.token_hash, str(binding.bound_ip), binding.status) + await update_binding_status(session, binding, STATUS_BANNED, binding_service) log_admin_action(request, settings, "ban", payload.id) return {"success": True} @@ -146,8 +168,6 @@ async def unban_token( binding_service: BindingService = Depends(get_binding_service), ): binding = await get_binding_or_404(session, payload.id) - binding.status = STATUS_ACTIVE - await session.commit() - await binding_service.sync_binding_cache(binding.token_hash, str(binding.bound_ip), binding.status) + await update_binding_status(session, binding, STATUS_ACTIVE, binding_service) log_admin_action(request, settings, "unban", payload.id) return {"success": True} diff --git a/app/api/logs.py b/app/api/logs.py index 6797828..9b37cf8 100644 --- a/app/api/logs.py +++ b/app/api/logs.py @@ -34,6 +34,33 @@ def apply_log_filters( return statement +def to_log_item(item: InterceptLog) -> InterceptLogItem: + return InterceptLogItem( + id=item.id, + token_display=item.token_display, + bound_ip=str(item.bound_ip), + attempt_ip=str(item.attempt_ip), + alerted=item.alerted, + intercepted_at=item.intercepted_at, + ) + + +def write_log_csv(buffer: io.StringIO, logs: list[InterceptLog]) -> None: + writer = csv.writer(buffer) + writer.writerow(["id", "token_display", "bound_ip", "attempt_ip", "alerted", "intercepted_at"]) + for item in logs: + writer.writerow( + [ + item.id, + item.token_display, + str(item.bound_ip), + str(item.attempt_ip), + item.alerted, + item.intercepted_at.isoformat(), + ] + ) + + @router.get("", response_model=LogListResponse) async def list_logs( page: int = Query(default=1, ge=1), @@ -54,17 +81,7 @@ async def list_logs( ).all() return LogListResponse( - items=[ - InterceptLogItem( - id=item.id, - token_display=item.token_display, - bound_ip=str(item.bound_ip), - attempt_ip=str(item.attempt_ip), - alerted=item.alerted, - intercepted_at=item.intercepted_at, - ) - for item in logs - ], + items=[to_log_item(item) for item in logs], total=total, page=page, page_size=page_size, @@ -85,19 +102,7 @@ async def export_logs( logs = (await session.scalars(statement)).all() buffer = io.StringIO() - writer = csv.writer(buffer) - writer.writerow(["id", "token_display", "bound_ip", "attempt_ip", "alerted", "intercepted_at"]) - for item in logs: - writer.writerow( - [ - item.id, - item.token_display, - str(item.bound_ip), - str(item.attempt_ip), - item.alerted, - item.intercepted_at.isoformat(), - ] - ) + write_log_csv(buffer, logs) filename = f"sentinel-logs-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}.csv" return StreamingResponse( diff --git a/frontend/package.json b/frontend/package.json index f1eb994..0519dc8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "@element-plus/icons-vue": "^2.3.1", "axios": "^1.8.3", "echarts": "^5.6.0", "element-plus": "^2.9.6", diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index baeac98..21ad81c 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -1,6 +1,7 @@ import axios from 'axios' const TOKEN_KEY = 'sentinel_admin_token' +const LOGIN_PATH_SUFFIX = '/login' export const api = axios.create({ baseURL: '/', @@ -19,15 +20,23 @@ api.interceptors.response.use( (response) => response, (error) => { if (error.response?.status === 401) { - clearAuthToken() - if (!window.location.pathname.endsWith('/login')) { - window.location.assign(`${import.meta.env.BASE_URL}login`) - } + redirectToLogin() } return Promise.reject(error) }, ) +function redirectToLogin() { + clearAuthToken() + if (!window.location.pathname.endsWith(LOGIN_PATH_SUFFIX)) { + window.location.assign(`${import.meta.env.BASE_URL}login`) + } +} + +function withData(promise) { + return promise.then((response) => response.data) +} + export function getAuthToken() { return localStorage.getItem(TOKEN_KEY) } @@ -45,59 +54,50 @@ export function humanizeError(error, fallback = 'Request failed.') { } export async function login(password) { - const { data } = await api.post('/admin/api/login', { password }) - return data + return withData(api.post('/admin/api/login', { password })) } export async function fetchDashboard() { - const { data } = await api.get('/admin/api/dashboard') - return data + return withData(api.get('/admin/api/dashboard')) } export async function fetchBindings(params) { - const { data } = await api.get('/admin/api/bindings', { params }) - return data + return withData(api.get('/admin/api/bindings', { params })) } export async function unbindBinding(id) { - const { data } = await api.post('/admin/api/bindings/unbind', { id }) - return data + return withData(api.post('/admin/api/bindings/unbind', { id })) } export async function updateBindingIp(payload) { - const { data } = await api.put('/admin/api/bindings/ip', payload) - return data + return withData(api.put('/admin/api/bindings/ip', payload)) } export async function banBinding(id) { - const { data } = await api.post('/admin/api/bindings/ban', { id }) - return data + return withData(api.post('/admin/api/bindings/ban', { id })) } export async function unbanBinding(id) { - const { data } = await api.post('/admin/api/bindings/unban', { id }) - return data + return withData(api.post('/admin/api/bindings/unban', { id })) } export async function fetchLogs(params) { - const { data } = await api.get('/admin/api/logs', { params }) - return data + return withData(api.get('/admin/api/logs', { params })) } export async function exportLogs(params) { - const response = await api.get('/admin/api/logs/export', { - params, - responseType: 'blob', - }) - return response.data + return withData( + api.get('/admin/api/logs/export', { + params, + responseType: 'blob', + }), + ) } export async function fetchSettings() { - const { data } = await api.get('/admin/api/settings') - return data + return withData(api.get('/admin/api/settings')) } export async function updateSettings(payload) { - const { data } = await api.put('/admin/api/settings', payload) - return data + return withData(api.put('/admin/api/settings', payload)) } diff --git a/frontend/src/composables/useAsyncAction.js b/frontend/src/composables/useAsyncAction.js new file mode 100644 index 0000000..8fc5b1e --- /dev/null +++ b/frontend/src/composables/useAsyncAction.js @@ -0,0 +1,25 @@ +import { ref } from 'vue' +import { ElMessage } from 'element-plus' + +import { humanizeError } from '../api' + +export function useAsyncAction() { + const loading = ref(false) + + async function run(task, fallbackMessage) { + loading.value = true + try { + return await task() + } catch (error) { + ElMessage.error(humanizeError(error, fallbackMessage)) + throw error + } finally { + loading.value = false + } + } + + return { + loading, + run, + } +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 6a46162..44f22da 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -195,6 +195,10 @@ body::before { gap: 24px; } +.content-grid--balanced { + grid-template-columns: minmax(0, 1.1fr) minmax(300px, 0.9fr); +} + .chart-card, .table-card, .form-card { @@ -235,6 +239,49 @@ body::before { gap: 16px; } +.support-card { + padding: 20px; + border-radius: 24px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(243, 251, 248, 0.72)); + border: 1px solid rgba(255, 255, 255, 0.32); +} + +.support-card h4 { + margin: 10px 0 8px; + font-size: 1.08rem; +} + +.support-card p { + margin: 0; + color: var(--sentinel-ink-soft); +} + +.support-list { + display: grid; + gap: 10px; +} + +.support-list-item { + display: grid; + gap: 4px; + padding: 12px 0; + border-top: 1px solid rgba(9, 22, 30, 0.08); +} + +.support-list-item:first-child { + padding-top: 0; + border-top: 0; +} + +.support-kpi { + display: grid; + gap: 8px; +} + +.support-kpi strong { + font-size: 1.55rem; +} + .insight-card { padding: 18px 20px; border-radius: 22px; @@ -282,6 +329,7 @@ body::before { padding: 42px; display: flex; flex-direction: column; + gap: 28px; justify-content: space-between; color: #f7fffe; background: @@ -328,6 +376,10 @@ body::before { font-size: 0.82rem; } +.status-chip .el-icon { + flex: 0 0 auto; +} + .stack { display: grid; gap: 24px; diff --git a/frontend/src/views/Bindings.vue b/frontend/src/views/Bindings.vue index 6c74945..5a22497 100644 --- a/frontend/src/views/Bindings.vue +++ b/frontend/src/views/Bindings.vue @@ -4,20 +4,23 @@ import { ElMessage, ElMessageBox } from 'element-plus' import MetricTile from '../components/MetricTile.vue' import PageHero from '../components/PageHero.vue' +import { useAsyncAction } from '../composables/useAsyncAction' import { banBinding, fetchBindings, - humanizeError, unbanBinding, unbindBinding, updateBindingIp, } from '../api' import { formatCompactNumber, formatDateTime, formatPercent } from '../utils/formatters' -const loading = ref(false) const dialogVisible = ref(false) const rows = ref([]) const total = ref(0) +const form = reactive({ + id: null, + bound_ip: '', +}) const filters = reactive({ token_suffix: '', ip: '', @@ -25,10 +28,7 @@ const filters = reactive({ page: 1, page_size: 20, }) -const form = reactive({ - id: null, - bound_ip: '', -}) +const { loading, run } = useAsyncAction() const activeCount = computed(() => rows.value.filter((item) => item.status === 1).length) const bannedCount = computed(() => rows.value.filter((item) => item.status === 2).length) @@ -38,6 +38,23 @@ const visibleProtectedRate = computed(() => { } return activeCount.value / rows.value.length }) +const opsCards = [ + { + eyebrow: 'Unbind', + title: 'Reset first-use bind', + note: 'Deletes the authoritative record and the Redis cache entry so the next request can bind again.', + }, + { + eyebrow: 'Edit CIDR', + title: 'Handle endpoint changes', + note: 'Update the bound IP or subnet when an internal user changes devices, locations, or network segments.', + }, + { + eyebrow: 'Ban', + title: 'Freeze compromised tokens', + note: 'Banned tokens are blocked immediately even if the client IP still matches the stored CIDR.', + }, +] function requestParams() { return { @@ -50,16 +67,11 @@ function requestParams() { } async function loadBindings() { - loading.value = true - try { + await run(async () => { const data = await fetchBindings(requestParams()) rows.value = data.items total.value = data.total - } catch (error) { - ElMessage.error(humanizeError(error, 'Failed to load bindings.')) - } finally { - loading.value = false - } + }, 'Failed to load bindings.') } function resetFilters() { @@ -82,13 +94,11 @@ async function submitEdit() { return } try { - await updateBindingIp({ id: form.id, bound_ip: form.bound_ip }) + await run(() => updateBindingIp({ id: form.id, bound_ip: form.bound_ip }), 'Failed to update binding.') ElMessage.success('Binding updated.') dialogVisible.value = false await loadBindings() - } catch (error) { - ElMessage.error(humanizeError(error, 'Failed to update binding.')) - } + } catch {} } async function confirmAction(title, action) { @@ -98,13 +108,12 @@ async function confirmAction(title, action) { cancelButtonText: 'Cancel', type: 'warning', }) - await action() + await run(action, 'Operation failed.') await loadBindings() } catch (error) { if (error === 'cancel') { return } - ElMessage.error(humanizeError(error, 'Operation failed.')) } } @@ -166,88 +175,117 @@ onMounted(() => { /> -
-
-
- - - - - - +
+
+
+
+ + + + + + +
+ +
+ Reset + Search +
-
- Reset - Search +
+ + + + + + + + + + + + + + + + +
-
-
- - - - - - - - - - - - - - - - - -
+
+ Page {{ filters.page }} of {{ Math.max(1, Math.ceil(total / filters.page_size)) }} + +
+
-
- Page {{ filters.page }} of {{ Math.max(1, Math.ceil(total / filters.page_size)) }} - -
+
diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index 7ccee5f..c8b71cf 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -1,15 +1,14 @@