@@ -0,0 +1,241 @@
< script setup lang = "ts" >
import { computed , onMounted , reactive , ref , watch } from 'vue'
import { useUserStore } from '@KTXC/stores/userStore'
import {
configuration , createRule , deleteRule , listLogs , listRules , maintenance ,
metrics , updateConfiguration , updateRule ,
} from '@/services/firewallService'
import type { FirewallConfiguration , FirewallLog , FirewallRule , RuleStatus , Scope } from '@/types/firewall'
const user = useUserStore ( )
const tab = ref ( 'rules' )
const scope = ref < Scope > ( 'tenant' )
const loading = ref ( false )
const error = ref ( '' )
const notice = ref ( '' )
const rules = ref < FirewallRule [ ] > ( [ ] )
const logs = ref < FirewallLog [ ] > ( [ ] )
const totalRules = ref ( 0 )
const totalLogs = ref ( 0 )
const ruleOffset = ref ( 0 )
const logOffset = ref ( 0 )
const blockedRequests = ref < number | null > ( null )
const maintenanceState = ref < Record < string , unknown > | null > ( null )
const status = ref < RuleStatus > ( 'active' )
const typeFilter = ref ( '' )
const actionFilter = ref ( '' )
const eventFilter = ref ( '' )
const resultFilter = ref ( '' )
const ipFilter = ref ( '' )
const canTenantRead = computed ( ( ) => user . hasPermission ( 'firewall.tenant.rules.read' ) )
const canTenantManage = computed ( ( ) => user . hasPermission ( 'firewall.tenant.rules.manage' ) )
const canSystemRead = computed ( ( ) => user . hasPermission ( 'firewall.system.rules.read' ) )
const canSystemManage = computed ( ( ) => user . hasPermission ( 'firewall.system.rules.manage' ) )
const canReadLogs = computed ( ( ) => user . hasPermission ( ` firewall. ${ scope . value } .logs.read ` ) )
const canManageRules = computed ( ( ) => scope . value === 'system' ? canSystemManage . value : canTenantManage . value )
const canReadSettings = computed ( ( ) => user . hasPermission ( 'firewall.tenant.settings.read' ) )
const canManageSettings = computed ( ( ) => user . hasPermission ( 'firewall.tenant.settings.manage' ) )
const canReadMaintenance = computed ( ( ) => user . hasPermission ( 'firewall.system.maintenance.read' ) )
const scopeOptions = computed ( ( ) => [
... ( canTenantRead . value ? [ { title : 'Tenant rules' , value : 'tenant' } ] : [ ] ) ,
... ( canSystemRead . value ? [ { title : 'System-wide rules' , value : 'system' } ] : [ ] ) ,
] )
const createOpen = ref ( false )
const createForm = reactive ( { type : 'ip' , action : 'block' , value : '' , reason : '' , durationSeconds : null as number | null , confirmCurrentIp : false } )
const actionOpen = ref ( false )
const selectedRule = ref < FirewallRule | null > ( null )
const selectedOperation = ref < 'disable' | 'enable' | 'extend' | 'delete' > ( 'disable' )
const actionReason = ref ( '' )
const extensionSeconds = ref < number | null > ( 3600 )
const confirmCurrentIp = ref ( false )
const settingsForm = reactive < FirewallConfiguration & { reason : string } > ( {
enabled : true , maxAuthFailures : 5 , authFailureWindow : 300 , autoBlockDuration : 3600 , reason : '' ,
} )
function message ( value : string , failure = false ) {
if ( failure ) error . value = value
else notice . value = value
}
async function loadRules ( ) {
if ( scope . value === 'system' ? ! canSystemRead . value : ! canTenantRead . value ) return
loading . value = true ; error . value = ''
try {
const page = await listRules ( scope . value , status . value , typeFilter . value || undefined , actionFilter . value || undefined , ruleOffset . value )
rules . value = page . items ; totalRules . value = page . total
} catch ( e ) { message ( e instanceof Error ? e . message : 'Unable to load rules' , true ) }
finally { loading . value = false }
}
async function loadLogs ( ) {
if ( ! canReadLogs . value ) return
loading . value = true ; error . value = ''
try {
const filters : Record < string , string > = { }
if ( eventFilter . value ) filters . eventType = eventFilter . value
if ( resultFilter . value ) filters . result = resultFilter . value
if ( ipFilter . value ) filters . ipAddress = ipFilter . value
const page = await listLogs ( scope . value , filters , logOffset . value )
logs . value = page . items ; totalLogs . value = page . total
} catch ( e ) { message ( e instanceof Error ? e . message : 'Unable to load logs' , true ) }
finally { loading . value = false }
}
async function loadOverview ( ) {
try { blockedRequests . value = ( await metrics ( scope . value ) ) . blockedRequests } catch { blockedRequests . value = null }
}
async function loadSettings ( ) {
if ( ! canReadSettings . value ) return
try { Object . assign ( settingsForm , await configuration ( ) , { reason : '' } ) }
catch ( e ) { message ( e instanceof Error ? e . message : 'Unable to load configuration' , true ) }
}
async function loadMaintenance ( ) {
if ( ! canReadMaintenance . value ) return
try { maintenanceState . value = await maintenance ( ) }
catch ( e ) { message ( e instanceof Error ? e . message : 'Unable to load maintenance status' , true ) }
}
async function submitCreate ( ) {
loading . value = true ; error . value = ''
try {
await createRule ( scope . value , createForm )
createOpen . value = false
Object . assign ( createForm , { type : 'ip' , action : 'block' , value : '' , reason : '' , durationSeconds : null , confirmCurrentIp : false } )
message ( 'Firewall rule created.' ) ; await loadRules ( )
} catch ( e ) { message ( e instanceof Error ? e . message : 'Unable to create rule' , true ) }
finally { loading . value = false }
}
function openAction ( rule : FirewallRule , operation : typeof selectedOperation . value ) {
selectedRule . value = rule ; selectedOperation . value = operation ; actionReason . value = ''
extensionSeconds . value = 3600 ; confirmCurrentIp . value = false ; actionOpen . value = true
}
async function submitAction ( ) {
if ( ! selectedRule . value ) return
loading . value = true ; error . value = ''
try {
if ( selectedOperation . value === 'delete' ) await deleteRule ( scope . value , selectedRule . value . id , actionReason . value )
else await updateRule ( scope . value , selectedRule . value . id , {
operation : selectedOperation . value , reason : actionReason . value ,
durationSeconds : selectedOperation . value === 'extend' ? extensionSeconds . value : null ,
confirmCurrentIp : confirmCurrentIp . value ,
} )
actionOpen . value = false ; message ( ` Rule ${ selectedOperation . value } operation completed. ` ) ; await loadRules ( )
} catch ( e ) { message ( e instanceof Error ? e . message : 'Unable to update rule' , true ) }
finally { loading . value = false }
}
async function saveSettings ( ) {
loading . value = true ; error . value = ''
try {
const result = await updateConfiguration ( settingsForm )
Object . assign ( settingsForm , result . configuration , { reason : '' } ) ; message ( 'Firewall configuration updated.' )
} catch ( e ) { message ( e instanceof Error ? e . message : 'Unable to update configuration' , true ) }
finally { loading . value = false }
}
function formatDate ( value : string | null | undefined ) : string {
return value ? new Intl . DateTimeFormat ( undefined , { dateStyle : 'medium' , timeStyle : 'short' } ) . format ( new Date ( value ) ) : 'Never'
}
watch ( scope , ( ) => { ruleOffset . value = 0 ; logOffset . value = 0 ; loadRules ( ) ; loadOverview ( ) ; if ( tab . value === 'logs' ) loadLogs ( ) } )
watch ( [ status , typeFilter , actionFilter ] , ( ) => { ruleOffset . value = 0 ; loadRules ( ) } )
watch ( tab , value => { if ( value === 'logs' ) loadLogs ( ) ; if ( value === 'settings' ) loadSettings ( ) ; if ( value === 'operations' ) loadMaintenance ( ) } )
onMounted ( ( ) => {
if ( ! canTenantRead . value && canSystemRead . value ) scope . value = 'system'
loadRules ( ) ; loadOverview ( )
} )
< / script >
< template >
< v-container fluid class = "pa-4 pa-md-6 firewall-page" >
< div class = "d-flex align-center flex-wrap ga-3 mb-6" >
< v-avatar color = "primary" rounded = "lg" size = "46" > < v-icon icon = "mdi-shield-lock-outline" color = "white" / > < / v-avatar >
< div >
< h1 class = "text-h5 font-weight-bold" > Firewall Manager < / h1 >
< p class = "text-body-2 text-medium-emphasis mb-0" > Manage policy , investigate events , and monitor protection . < / p >
< / div >
< v-spacer / >
< v-select v-if = "scopeOptions.length > 1" v-model="scope" :items="scopeOptions" density="compact" variant="outlined" hide-details class="scope-select" / >
< v-btn icon = "mdi-refresh" variant = "tonal" :loading = "loading" aria -label = " Refresh " @click ="tab === 'logs' ? loadLogs() : loadRules()" / >
< / div >
< v-alert v-if = "error" type="error" variant="tonal" closable class="mb-4" @click:close="error = ''" > {{ error }} < / v-alert >
< v -alert v-if = "notice" type="success" variant="tonal" closable class="mb-4" @click:close="notice = ''" > {{ notice }} < / v-alert >
< v -row class = "mb-3" >
< v-col cols = "12" sm = "6" md = "3" > < v-card rounded = "lg" variant = "tonal" > < v-card-text > < div class = "text-caption text-medium-emphasis" > Blocked requests < / div > < div class = "text-h4 font-weight-bold" > { { blockedRequests ? ? '—' } } < / div > < / v-card-text > < / v-card > < / v-col >
< v-col cols = "12" sm = "6" md = "3" > < v-card rounded = "lg" variant = "tonal" > < v-card-text > < div class = "text-caption text-medium-emphasis" > Rules in view < / div > < div class = "text-h4 font-weight-bold" > { { totalRules } } < / div > < / v-card-text > < / v-card > < / v-col >
< v-col cols = "12" sm = "6" md = "3" > < v-card rounded = "lg" variant = "tonal" > < v-card-text > < div class = "text-caption text-medium-emphasis" > Policy scope < / div > < div class = "text-h6 font-weight-bold text-capitalize" > { { scope } } < / div > < / v-card-text > < / v-card > < / v-col >
< / v-row >
< v-card rounded = "lg" border >
< v-tabs v-model = "tab" color="primary" >
< v -tab value = "rules" prepend -icon = " mdi -format -list -bulleted " > Rules < / v-tab >
< v-tab v-if = "canReadLogs" value="logs" prepend-icon="mdi-text-box-search-outline" > Logs < / v-tab >
< v -tab v-if = "canReadSettings" value="settings" prepend-icon="mdi-tune-variant" > Settings < / v-tab >
< v -tab v-if = "canReadMaintenance" value="operations" prepend-icon="mdi-wrench-clock" > Operations < / v-tab >
< / v-tabs >
< v -divider / >
< v-window v-model = "tab" >
< v -window -item value = "rules" >
< v-card-text >
< div class = "d-flex ga-3 flex-wrap align-center mb-4" >
< v-select v-model = "status" :items="['active','disabled','expired','all']" label="Status" density="compact" variant="outlined" hide-details class="filter" / >
< v-select v-model = "typeFilter" :items="[{title:'All types',value:''},{title:'IP address',value:'ip'},{title:'CIDR range',value:'ip_range'},{title:'Device',value:'device'}]" label="Type" density="compact" variant="outlined" hide-details class="filter" / >
< v-select v-model = "actionFilter" :items="[{title:'All actions',value:''},{title:'Block',value:'block'},{title:'Allow',value:'allow'}]" label="Action" density="compact" variant="outlined" hide-details class="filter" / >
< v-spacer / >
< v-btn v-if = "canManageRules" color="primary" prepend-icon="mdi-plus" @click="createOpen = true" > Create rule < / v-btn >
< / div >
< v -progress -linear v-if = "loading" indeterminate class="mb-3" / >
< v-table hover >
< thead > < tr > < th > Rule < / th > < th > Action < / th > < th > Reason < / th > < th > Expires < / th > < th > Status < / th > < th v-if = "canManageRules" class="text-right" > Actions < / th > < / tr > < / thead >
< tbody >
< tr v-for = "rule in rules" :key="rule.id" >
< td > < div class = "font-weight-medium" > { { rule . value } } < / div > < div class = "text-caption text-medium-emphasis" > { { rule . type . replace ( '_' , ' ' ) } } < / div > < / td >
< td > < v-chip : color = "rule.action === 'block' ? 'error' : 'success'" size = "small" variant = "tonal" > { { rule . action } } < / v-chip > < / td >
< td > { { rule . reason } } < / td > < td > { { formatDate ( rule . expiresAt ) } } < / td >
< td > < v-chip : color = "rule.enabled ? 'success' : 'warning'" size = "small" variant = "tonal" > { { rule . enabled ? 'Enabled' : 'Disabled' } } < / v-chip > < / td >
< td v-if = "canManageRules" class="text-right text-no-wrap" >
< v -btn v-if = "rule.enabled" icon="mdi-pause" size="small" variant="text" aria-label="Disable" @click="openAction(rule, 'disable')" / >
< v-btn v-else icon = "mdi-play" size = "small" variant = "text" aria -label = " Enable " @click ="openAction(rule, 'enable')" / >
< v-btn v-if = "rule.expiresAt" icon="mdi-clock-plus-outline" size="small" variant="text" aria-label="Extend" @click="openAction(rule, 'extend')" / >
< v-btn icon = "mdi-delete-outline" color = "error" size = "small" variant = "text" aria -label = " Delete " @click ="openAction(rule, 'delete')" / >
< / td >
< / tr >
< tr v-if = "!rules.length"><td :colspan="canManageRules ? 6 : 5" class="text-center text-medium-emphasis py-8" > No rules match these filters. < / td > < / tr >
< / tbody >
< / v-table >
< div class = "d-flex justify-end align-center ga-2 mt-4" > < span class = "text-caption" > { { totalRules ? ruleOffset + 1 : 0 } } – { { Math . min ( ruleOffset + 25 , totalRules ) } } of { { totalRules } } < / span > < v-btn icon = "mdi-chevron-left" size = "small" : disabled = "ruleOffset === 0" @click ="ruleOffset -= 25; loadRules()" / > < v-btn icon = "mdi-chevron-right" size = "small" : disabled = "ruleOffset + 25 >= totalRules" @click ="ruleOffset += 25; loadRules()" / > < / div >
< / v-card-text >
< / v-window-item >
< v-window-item value = "logs" > < v-card-text >
< div class = "d-flex ga-3 flex-wrap mb-4" > < v-text-field v-model = "ipFilter" label="IP address" density="compact" variant="outlined" hide-details class="filter" /><v-text-field v-model="eventFilter" label="Event type" density="compact" variant="outlined" hide-details class="filter" /><v-select v-model="resultFilter" :items="[{title:'All results',value:''},{title:'Blocked',value:'blocked'},{title:'Allowed',value:'allowed'},{title:'Recorded',value:'recorded'}]" label="Result" density="compact" variant="outlined" hide-details class="filter" /><v-btn prepend-icon="mdi-magnify" variant="tonal" @click="logOffset = 0; loadLogs()" > Apply < / v-btn > < / div >
< v -table hover > < thead > < tr > < th > Time < / th > < th > Event < / th > < th > Result < / th > < th > IP < / th > < th > Rule < / th > < th > Actor < / th > < / tr > < / thead > < tbody > < tr v-for = "entry in logs" :key="entry.id"><td>{{ formatDate(entry.timestamp) }}</td><td>{{ entry.eventType }}</td><td><v-chip size="small" variant="tonal" :color="entry.result === 'blocked' ? 'error' : entry.result === 'allowed' ? 'success' : 'info'">{{ entry.result }}</v-chip></td><td>{{ entry.ipAddress || '—' }}</td><td>{{ entry.ruleId || '—' }}</td><td>{{ entry.identityId || '—' }}</td></tr><tr v-if="!logs.length"><td colspan="6" class="text-center text-medium-emphasis py-8" > No audit events match these filters. < / td > < / tr > < / tbody > < / v-table >
< div class = "d-flex justify-end align-center ga-2 mt-4" > < span class = "text-caption" > { { totalLogs ? logOffset + 1 : 0 } } – { { Math . min ( logOffset + 25 , totalLogs ) } } of { { totalLogs } } < / span > < v-btn icon = "mdi-chevron-left" size = "small" : disabled = "logOffset === 0" @click ="logOffset -= 25; loadLogs()" / > < v-btn icon = "mdi-chevron-right" size = "small" : disabled = "logOffset + 25 >= totalLogs" @click ="logOffset += 25; loadLogs()" / > < / div >
< / v-card-text > < / v-window-item >
< v-window-item value = "settings" > < v-card-text > < v-alert v-if = "!canManageSettings" type="info" variant="tonal" class="mb-4">You can view these settings but cannot change them.</v-alert><v-form @submit.prevent="saveSettings"><v-switch v-model="settingsForm.enabled" label="Enable tenant firewall rules" color="primary" :disabled="!canManageSettings" /><v-row><v-col cols="12" md="4"><v-number-input v-model="settingsForm.maxAuthFailures" label="Maximum authentication failures" :min="1" :max="1000" variant="outlined" :disabled="!canManageSettings" /></v-col><v-col cols="12" md="4"><v-number-input v-model="settingsForm.authFailureWindow" label="Failure window (seconds)" :min="1" :max="86400" variant="outlined" :disabled="!canManageSettings" /></v-col><v-col cols="12" md="4"><v-number-input v-model="settingsForm.autoBlockDuration" label="Automatic block duration (seconds)" :min="1" :max="31536000" variant="outlined" :disabled="!canManageSettings" /></v-col></v-row><v-textarea v-if="canManageSettings" v-model="settingsForm.reason" label="Reason for this change" rows="2" counter="1000" variant="outlined" /><v-btn v-if="canManageSettings" type="submit" color="primary" :disabled="!settingsForm.reason.trim()" :loading="loading" > Save settings < / v-btn > < / v-form > < / v-card-text > < / v-window-item >
< v -window -item value = "operations" > < v-card-text > < h2 class = "text-h6 mb-3" > Maintenance status < / h2 > < v-list v-if = "maintenanceState" lines="two" border rounded="lg"><v-list-item title="Status" :subtitle="String(maintenanceState.status ?? 'never_run')" prepend-icon="mdi-database-clock" /><v-list-item title="Last completed" :subtitle="formatDate(maintenanceState.completedAt as string | undefined)" prepend-icon="mdi-clock-check-outline" /><v-list-item v-if="maintenanceState.error" title="Last error" :subtitle="String(maintenanceState.error)" prepend-icon="mdi-alert-circle-outline" /></v-list><v-empty-state v-else icon="mdi-wrench-clock" title="No maintenance result" text="Maintenance has not run yet." / > < / v-card-text > < / v-window-item >
< / v-window >
< / v-card >
< v-dialog v-model = "createOpen" max-width="620"><v-card title="Create firewall rule" prepend-icon="mdi-shield-plus-outline"><v-card-text><v-row><v-col cols="6"><v-select v-model="createForm.type" :items="[{title:'IP address',value:'ip'},{title:'CIDR range',value:'ip_range'},{title:'Device',value:'device'}]" label="Type" variant="outlined" /></v-col><v-col cols="6"><v-select v-model="createForm.action" :items="[{title:'Block',value:'block'},{title:'Allow',value:'allow'}]" label="Action" variant="outlined" /></v-col></v-row><v-text-field v-model="createForm.value" label="Value" variant="outlined" /><v-number-input v-if="createForm.action === 'block' && ['ip','device'].includes(createForm.type)" v-model="createForm.durationSeconds" label="Temporary duration in seconds (optional)" :min="1" variant="outlined" /><v-textarea v-model="createForm.reason" label="Administrative reason" rows="2" counter="1000" variant="outlined" /><v-checkbox v-if="createForm.action === 'block' && ['ip','ip_range'].includes(createForm.type)" v-model="createForm.confirmCurrentIp" label="I understand this may block my current IP address" color="warning" /></v-card-text><v-card-actions><v-spacer /><v-btn variant="text" @click="createOpen = false">Cancel</v-btn><v-btn color="primary" :loading="loading" :disabled="!createForm.value.trim() || !createForm.reason.trim()" @click="submitCreate" > Create < / v-btn > < / v-card-actions > < / v-card > < / v-dialog >
< v -dialog v-model = "actionOpen" max-width="560"><v-card :title="`${selectedOperation[0].toUpperCase() + selectedOperation.slice(1)} rule`"><v-card-text><v-alert v-if="selectedOperation === 'delete'" type="warning" variant="tonal" class="mb-4">Deletion permanently removes the rule. Its audit history remains available.</v-alert><p class="mb-4"><strong>{{ selectedRule?.value }}</strong></p><v-number-input v-if="selectedOperation === 'extend'" v-model="extensionSeconds" label="Extension in seconds" :min="1" variant="outlined" /><v-textarea v-model="actionReason" label="Administrative reason" rows="2" counter="1000" variant="outlined" /><v-checkbox v-if="selectedOperation === 'enable' && selectedRule?.action === 'block'" v-model="confirmCurrentIp" label="Confirm even if this blocks my current IP address" color="warning" /></v-card-text><v-card-actions><v-spacer /><v-btn variant="text" @click="actionOpen = false">Cancel</v-btn><v-btn :color="selectedOperation === 'delete' ? 'error' : 'primary'" :disabled="!actionReason.trim() || (selectedOperation === 'extend' && !extensionSeconds)" :loading="loading" @click="submitAction" > Confirm < / v-btn > < / v-card-actions > < / v-card > < / v-dialog >
< / v-container >
< / template >
< style scoped >
. firewall - page { max - width : 1600 px ; margin : 0 auto ; }
. scope - select { min - width : 210 px ; max - width : 260 px ; }
. filter { min - width : 170 px ; max - width : 240 px ; }
< / style >