561 lines
15 KiB
Vue
561 lines
15 KiB
Vue
|
|
<script setup>
|
|||
|
|
import { ref, onMounted, computed, watch } from 'vue'
|
|||
|
|
import { useAuth } from '~/composables/useAuth.js'
|
|||
|
|
|
|||
|
|
const route = useRoute()
|
|||
|
|
const router = useRouter()
|
|||
|
|
|
|||
|
|
const {
|
|||
|
|
isAuthenticated,
|
|||
|
|
isInitialized,
|
|||
|
|
userProfile,
|
|||
|
|
initializeAuth,
|
|||
|
|
logout
|
|||
|
|
} = useAuth()
|
|||
|
|
|
|||
|
|
const logs = ref([])
|
|||
|
|
const totalLogs = ref(0)
|
|||
|
|
const isLoading = ref(true)
|
|||
|
|
const errorMessage = ref('')
|
|||
|
|
|
|||
|
|
const searchQuery = ref(String(route.query.q || ''))
|
|||
|
|
const selectedType = ref(String(route.query.type || 'all'))
|
|||
|
|
const dateFrom = ref(String(route.query.from || ''))
|
|||
|
|
const dateTo = ref(String(route.query.to || ''))
|
|||
|
|
const currentPage = ref(Number(route.query.page) || 1)
|
|||
|
|
const limit = ref(Number(route.query.limit) || 20)
|
|||
|
|
|
|||
|
|
const FIELD_LABELS = {
|
|||
|
|
account_level: 'Access Level',
|
|||
|
|
country: 'Country',
|
|||
|
|
language: 'Language',
|
|||
|
|
timezone: 'Timezone',
|
|||
|
|
gender: 'Gender',
|
|||
|
|
email: 'Email',
|
|||
|
|
marketing_allowed: 'Marketing Allowed',
|
|||
|
|
off_device_allowed: 'Off-Device Allowed'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const totalPages = computed(() => Math.ceil(totalLogs.value / limit.value) || 1)
|
|||
|
|
|
|||
|
|
const visiblePages = computed(() => {
|
|||
|
|
const pages = []
|
|||
|
|
const maxVisible = 5
|
|||
|
|
let start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2))
|
|||
|
|
let end = Math.min(totalPages.value, start + maxVisible - 1)
|
|||
|
|
|
|||
|
|
if (end - start + 1 < maxVisible) {
|
|||
|
|
start = Math.max(1, end - maxVisible + 1)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for (let i = start; i <= end; i++) {
|
|||
|
|
pages.push(i)
|
|||
|
|
}
|
|||
|
|
return pages
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const formatDate = (isoStr) => {
|
|||
|
|
if (!isoStr) return 'N/A'
|
|||
|
|
try {
|
|||
|
|
return new Date(isoStr).toLocaleString('en-US', {
|
|||
|
|
dateStyle: 'medium',
|
|||
|
|
timeStyle: 'short'
|
|||
|
|
})
|
|||
|
|
} catch {
|
|||
|
|
return isoStr
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const parseLogChanges = (rawChanges) => {
|
|||
|
|
if (!rawChanges) return { changes: [], reason: '', mod_note: '' }
|
|||
|
|
if (typeof rawChanges === 'string') {
|
|||
|
|
try {
|
|||
|
|
return JSON.parse(rawChanges)
|
|||
|
|
} catch {
|
|||
|
|
return { changes: [], reason: '', mod_note: '' }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return rawChanges
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const updateUrlParams = () => {
|
|||
|
|
const query = {}
|
|||
|
|
|
|||
|
|
if (searchQuery.value.trim()) query.q = searchQuery.value.trim()
|
|||
|
|
if (selectedType.value && selectedType.value !== 'all') query.type = selectedType.value
|
|||
|
|
if (dateFrom.value) query.from = dateFrom.value
|
|||
|
|
if (dateTo.value) query.to = dateTo.value
|
|||
|
|
if (currentPage.value > 1) query.page = currentPage.value
|
|||
|
|
if (limit.value !== 20) query.limit = limit.value
|
|||
|
|
|
|||
|
|
router.replace({ query })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const fetchLogs = async () => {
|
|||
|
|
isLoading.value = true
|
|||
|
|
errorMessage.value = ''
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const queryParams = new URLSearchParams({
|
|||
|
|
page: currentPage.value,
|
|||
|
|
limit: limit.value,
|
|||
|
|
search: searchQuery.value,
|
|||
|
|
type: selectedType.value
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if (dateFrom.value) queryParams.append('from', dateFrom.value)
|
|||
|
|
if (dateTo.value) queryParams.append('to', dateTo.value)
|
|||
|
|
|
|||
|
|
const response = await $fetch(`/api/admin/audit-logs?${queryParams.toString()}`)
|
|||
|
|
logs.value = response.logs || []
|
|||
|
|
totalLogs.value = response.total || 0
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('Failed to fetch audit logs:', err)
|
|||
|
|
errorMessage.value = err.data?.statusMessage || 'Failed to fetch audit logs.'
|
|||
|
|
} finally {
|
|||
|
|
isLoading.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let searchDebounceTimeout = null
|
|||
|
|
watch(searchQuery, () => {
|
|||
|
|
clearTimeout(searchDebounceTimeout)
|
|||
|
|
searchDebounceTimeout = setTimeout(() => {
|
|||
|
|
currentPage.value = 1
|
|||
|
|
updateUrlParams()
|
|||
|
|
fetchLogs()
|
|||
|
|
}, 400)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
watch([selectedType, dateFrom, dateTo, limit], () => {
|
|||
|
|
currentPage.value = 1
|
|||
|
|
updateUrlParams()
|
|||
|
|
fetchLogs()
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const openLogDetails = (log) => {
|
|||
|
|
const targetLogId = log.log_id || log.id
|
|||
|
|
navigateTo(`/audit/log/${targetLogId}`)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const goToPage = (page) => {
|
|||
|
|
if (page < 1 || page > totalPages.value || page === currentPage.value) return
|
|||
|
|
currentPage.value = page
|
|||
|
|
updateUrlParams()
|
|||
|
|
fetchLogs()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleLogout = () => logout()
|
|||
|
|
const handleNavigate = (path) => navigateTo(path)
|
|||
|
|
|
|||
|
|
onMounted(async () => {
|
|||
|
|
await initializeAuth({ redirectTo: '/oauth' })
|
|||
|
|
if (isAuthenticated.value) {
|
|||
|
|
await fetchLogs()
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<div>
|
|||
|
|
<div v-if="!isInitialized" class="global-loader-wrapper">
|
|||
|
|
<div class="spinner lg"></div>
|
|||
|
|
<p style="color: #8b949e; margin-top: 1rem; font-size: 0.95rem;">Connecting to Keycloak...</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-else-if="isAuthenticated" class="settings-container">
|
|||
|
|
<Header
|
|||
|
|
:user-profile="userProfile"
|
|||
|
|
active-tab="audit"
|
|||
|
|
@logout="handleLogout"
|
|||
|
|
@navigate="handleNavigate"
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<main class="settings-main-content">
|
|||
|
|
<section class="settings-panel" style="width: 100%;">
|
|||
|
|
<div class="panel-header archivo-black" style="display: flex; justify-content: space-between; align-items: center;">
|
|||
|
|
<span>SYSTEM AUDIT LOGS</span>
|
|||
|
|
<span class="badge-count">{{ totalLogs }} total entries</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="panel-body" style="padding: 1.5rem;">
|
|||
|
|
|
|||
|
|
<div class="filter-bar">
|
|||
|
|
<div class="search-box">
|
|||
|
|
<input
|
|||
|
|
v-model="searchQuery"
|
|||
|
|
type="text"
|
|||
|
|
class="input-field"
|
|||
|
|
placeholder="Search by Log ID, Editor, Target, or PID..."
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="filter-group">
|
|||
|
|
<select v-model="selectedType" class="input-field select-filter">
|
|||
|
|
<option value="all">All Audit Logs</option>
|
|||
|
|
<option value="ban">Ban Logs Only</option>
|
|||
|
|
</select>
|
|||
|
|
|
|||
|
|
<input
|
|||
|
|
v-model="dateFrom"
|
|||
|
|
type="date"
|
|||
|
|
class="input-field date-filter"
|
|||
|
|
title="Filter from date"
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<input
|
|||
|
|
v-model="dateTo"
|
|||
|
|
type="date"
|
|||
|
|
class="input-field date-filter"
|
|||
|
|
title="Filter to date"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-if="errorMessage" class="alert-error">
|
|||
|
|
{{ errorMessage }}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-if="isLoading" class="loading-state">
|
|||
|
|
<div class="spinner sm"></div>
|
|||
|
|
<span>Fetching audit logs...</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-else-if="logs.length > 0" class="table-wrapper">
|
|||
|
|
<table class="audit-table">
|
|||
|
|
<thead>
|
|||
|
|
<tr>
|
|||
|
|
<th>LOG ID</th>
|
|||
|
|
<th>TIMESTAMP</th>
|
|||
|
|
<th>EDITOR</th>
|
|||
|
|
<th>TARGET USER</th>
|
|||
|
|
<th>CHANGES SUMMARY</th>
|
|||
|
|
<th>ACTION</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody>
|
|||
|
|
<tr v-for="log in logs" :key="log.log_id || log.id">
|
|||
|
|
<td class="font-mono text-dim">
|
|||
|
|
#{{ log.log_id || log.id }}
|
|||
|
|
</td>
|
|||
|
|
<td class="text-nowrap">
|
|||
|
|
{{ formatDate(log.created_at) }}
|
|||
|
|
</td>
|
|||
|
|
<td>
|
|||
|
|
<div class="user-cell">
|
|||
|
|
<img
|
|||
|
|
:src="log.editor_pid ? `https://mii.spfn.net/${log.editor_pid}/main.png` : '/img_unknown_MiiIcon.png'"
|
|||
|
|
class="avatar-xs"
|
|||
|
|
@error="(e) => e.target.src = '/img_unknown_MiiIcon.png'"
|
|||
|
|
/>
|
|||
|
|
<span>{{ log.editor_sfid || 'System / Unknown' }}</span>
|
|||
|
|
</div>
|
|||
|
|
</td>
|
|||
|
|
<td>
|
|||
|
|
<div class="user-cell">
|
|||
|
|
<img
|
|||
|
|
:src="log.target_pid ? `https://mii.spfn.net/${log.target_pid}/main.png` : '/img_unknown_MiiIcon.png'"
|
|||
|
|
class="avatar-xs"
|
|||
|
|
@error="(e) => e.target.src = '/img_unknown_MiiIcon.png'"
|
|||
|
|
/>
|
|||
|
|
<div>
|
|||
|
|
<strong>{{ log.target_name }}</strong>
|
|||
|
|
<span class="pid-tag">PID: {{ log.target_pid }}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</td>
|
|||
|
|
<td>
|
|||
|
|
<div class="changes-summary">
|
|||
|
|
<template v-if="log.ban_level !== undefined && log.ban_level !== null">
|
|||
|
|
<span class="badge-ban">Banned (Level {{ log.ban_level }})</span>
|
|||
|
|
</template>
|
|||
|
|
<template v-else>
|
|||
|
|
<span
|
|||
|
|
v-for="change in parseLogChanges(log.changes).changes"
|
|||
|
|
:key="change.field"
|
|||
|
|
class="change-chip"
|
|||
|
|
>
|
|||
|
|
{{ FIELD_LABELS[change.field] || change.field }}
|
|||
|
|
</span>
|
|||
|
|
</template>
|
|||
|
|
</div>
|
|||
|
|
</td>
|
|||
|
|
<td>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
class="btn-action secondary sm"
|
|||
|
|
@click="openLogDetails(log)"
|
|||
|
|
>
|
|||
|
|
Inspect
|
|||
|
|
</button>
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-else class="empty-state">
|
|||
|
|
<p>No audit logs found matching your criteria.</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="pagination-bar">
|
|||
|
|
<div class="per-page-selector">
|
|||
|
|
<label for="limit-select">Show:</label>
|
|||
|
|
<select id="limit-select" v-model="limit" class="input-field select-sm">
|
|||
|
|
<option :value="10">10</option>
|
|||
|
|
<option :value="20">20</option>
|
|||
|
|
<option :value="50">50</option>
|
|||
|
|
<option :value="100">100</option>
|
|||
|
|
</select>
|
|||
|
|
<span class="range-info">
|
|||
|
|
Showing {{ ((currentPage - 1) * limit) + 1 }}–{{ Math.min(currentPage * limit, totalLogs) }} of {{ totalLogs }}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="pagination-controls">
|
|||
|
|
<button
|
|||
|
|
class="btn-action secondary sm"
|
|||
|
|
:disabled="currentPage === 1"
|
|||
|
|
title="First Page"
|
|||
|
|
@click="goToPage(1)"
|
|||
|
|
>
|
|||
|
|
«
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
class="btn-action secondary sm"
|
|||
|
|
:disabled="currentPage === 1"
|
|||
|
|
title="Previous Page"
|
|||
|
|
@click="goToPage(currentPage - 1)"
|
|||
|
|
>
|
|||
|
|
←
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
<button
|
|||
|
|
v-for="page in visiblePages"
|
|||
|
|
:key="page"
|
|||
|
|
class="btn-action sm page-number"
|
|||
|
|
:class="{ active: page === currentPage }"
|
|||
|
|
@click="goToPage(page)"
|
|||
|
|
>
|
|||
|
|
{{ page }}
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
<button
|
|||
|
|
class="btn-action secondary sm"
|
|||
|
|
:disabled="currentPage === totalPages"
|
|||
|
|
title="Next Page"
|
|||
|
|
@click="goToPage(currentPage + 1)"
|
|||
|
|
>
|
|||
|
|
→
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
class="btn-action secondary sm"
|
|||
|
|
:disabled="currentPage === totalPages"
|
|||
|
|
title="Last Page"
|
|||
|
|
@click="goToPage(totalPages)"
|
|||
|
|
>
|
|||
|
|
»
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
</main>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.filter-bar {
|
|||
|
|
display: flex;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
gap: 0.75rem;
|
|||
|
|
margin-bottom: 1.5rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.search-box {
|
|||
|
|
flex: 1;
|
|||
|
|
min-width: 280px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.filter-group {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 0.5rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.select-filter, .date-filter {
|
|||
|
|
width: auto;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.table-wrapper {
|
|||
|
|
overflow-x: auto;
|
|||
|
|
border: 1px solid #30363d;
|
|||
|
|
border-radius: 6px;
|
|||
|
|
background: #0d1117;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.audit-table {
|
|||
|
|
width: 100%;
|
|||
|
|
border-collapse: collapse;
|
|||
|
|
font-size: 0.875rem;
|
|||
|
|
text-align: left;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.audit-table th {
|
|||
|
|
background: #161b22;
|
|||
|
|
color: #8b949e;
|
|||
|
|
padding: 0.75rem 1rem;
|
|||
|
|
font-size: 0.75rem;
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
border-bottom: 1px solid #30363d;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.audit-table td {
|
|||
|
|
padding: 0.85rem 1rem;
|
|||
|
|
border-bottom: 1px solid #21262d;
|
|||
|
|
color: #c9d1d9;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.audit-table tr:last-child td {
|
|||
|
|
border-bottom: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.user-cell {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.6rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.avatar-xs {
|
|||
|
|
width: 28px;
|
|||
|
|
height: 28px;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
background: #161b22;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.pid-tag {
|
|||
|
|
display: block;
|
|||
|
|
font-size: 0.75rem;
|
|||
|
|
color: #8b949e;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.changes-summary {
|
|||
|
|
display: flex;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
gap: 0.35rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.change-chip {
|
|||
|
|
background: #21262d;
|
|||
|
|
border: 1px solid #30363d;
|
|||
|
|
color: #58a6ff;
|
|||
|
|
padding: 0.15rem 0.5rem;
|
|||
|
|
border-radius: 12px;
|
|||
|
|
font-size: 0.75rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.badge-ban {
|
|||
|
|
background: #490202;
|
|||
|
|
border: 1px solid #8b0000;
|
|||
|
|
color: #ff7b72;
|
|||
|
|
padding: 0.15rem 0.5rem;
|
|||
|
|
border-radius: 12px;
|
|||
|
|
font-size: 0.75rem;
|
|||
|
|
font-weight: bold;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.badge-count {
|
|||
|
|
font-size: 0.8rem;
|
|||
|
|
background: #21262d;
|
|||
|
|
color: #8b949e;
|
|||
|
|
padding: 0.2rem 0.6rem;
|
|||
|
|
border-radius: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.pagination-bar {
|
|||
|
|
display: flex;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 1rem;
|
|||
|
|
margin-top: 1.25rem;
|
|||
|
|
padding-top: 1rem;
|
|||
|
|
border-top: 1px solid #30363d;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.per-page-selector {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.5rem;
|
|||
|
|
font-size: 0.85rem;
|
|||
|
|
color: #8b949e;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.select-sm {
|
|||
|
|
padding: 0.2rem 0.4rem;
|
|||
|
|
font-size: 0.85rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.range-info {
|
|||
|
|
margin-left: 0.5rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.pagination-controls {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.35rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.page-number {
|
|||
|
|
background: transparent;
|
|||
|
|
border: 1px solid #30363d;
|
|||
|
|
color: #c9d1d9;
|
|||
|
|
min-width: 32px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.page-number.active {
|
|||
|
|
background: #238636;
|
|||
|
|
border-color: #2ea043;
|
|||
|
|
color: #ffffff;
|
|||
|
|
font-weight: bold;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.empty-state, .loading-state {
|
|||
|
|
text-align: center;
|
|||
|
|
padding: 3rem 1rem;
|
|||
|
|
color: #8b949e;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.alert-error {
|
|||
|
|
padding: 0.85rem;
|
|||
|
|
background: #221515;
|
|||
|
|
border: 1px solid #f85149;
|
|||
|
|
color: #f85149;
|
|||
|
|
border-radius: 6px;
|
|||
|
|
margin-bottom: 1rem;
|
|||
|
|
font-size: 0.9rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.input-field {
|
|||
|
|
background: #161b22;
|
|||
|
|
border: 1px solid #30363d;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
color: #f0f6fc;
|
|||
|
|
padding: 0.4rem 0.6rem;
|
|||
|
|
font-size: 0.9rem;
|
|||
|
|
outline: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-action {
|
|||
|
|
border-radius: 6px;
|
|||
|
|
padding: 0.4rem 0.85rem;
|
|||
|
|
font-size: 0.8rem;
|
|||
|
|
font-weight: 600;
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-action.secondary { background: transparent; border: 1px solid #30363d; color: #c9d1d9; }
|
|||
|
|
.btn-action.sm { padding: 0.25rem 0.6rem; font-size: 0.75rem; }
|
|||
|
|
.btn-action:disabled { opacity: 0.35; cursor: not-allowed; }
|
|||
|
|
</style>
|