Merge remote-tracking branch 'origin/main' into wilm
# Conflicts: # shopdb/__init__.py
This commit is contained in:
@@ -11,7 +11,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-search">
|
||||
<!-- Global search is authenticated; offering the box to a signed-out
|
||||
reader gives them a control that can only answer 401. -->
|
||||
<div v-if="authStore.isAuthenticated" class="sidebar-search">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
@@ -34,6 +36,8 @@
|
||||
<a v-if="hasRoute('/tv')" :href="withBase('/tv')" target="_blank" class="external-link">TV Slideshow</a>
|
||||
<a v-if="hasRoute('/parts-kiosk')" :href="withBase('/parts-kiosk')"
|
||||
target="_blank" class="external-link">Parts Kiosk</a>
|
||||
<a v-if="hasRoute('/usb-kiosk')" :href="withBase('/usb-kiosk')"
|
||||
target="_blank" class="external-link">USB Kiosk</a>
|
||||
|
||||
<router-link v-if="authStore.isAdmin" to="/settings">Settings</router-link>
|
||||
</nav>
|
||||
@@ -136,6 +140,7 @@ const hasRoute = (path) => stagedRoutePaths.has(path)
|
||||
const showShopfloor = hasRoute('/shopfloor')
|
||||
&& (hasRoute('/notifications') || hasRoute('/calendar'))
|
||||
const showDisplays = showShopfloor || hasRoute('/tv') || hasRoute('/parts-kiosk')
|
||||
|| hasRoute('/usb-kiosk')
|
||||
const routeViewKey = computed(() =>
|
||||
route.path.startsWith('/settings') ? '/settings' : route.path
|
||||
)
|
||||
@@ -183,50 +188,81 @@ const iconMap = {
|
||||
|
||||
// Default navigation (used as fallback if API fails)
|
||||
const defaultNav = [
|
||||
{ name: 'Dashboard', icon: 'layout-dashboard', route: '/', position: 0 },
|
||||
{ name: 'Notifications', icon: 'bell', route: '/notifications', position: 5 },
|
||||
{ name: 'Calendar', icon: 'calendar', route: '/calendar', position: 6 },
|
||||
{ name: 'Map', icon: 'map', route: '/map', position: 4 },
|
||||
{ name: 'Machines', icon: 'cog', route: '/machines', position: 10 },
|
||||
{ name: 'PCs', icon: 'desktop', route: '/pcs', position: 15 },
|
||||
{ name: 'Network', icon: 'network-wired', route: '/network', position: 18 },
|
||||
{ name: 'Printers', icon: 'printer', route: '/printers', position: 20 },
|
||||
{ name: 'USB Devices', icon: 'usb', route: '/usb', position: 45 },
|
||||
{ name: 'Applications', icon: 'app-window', route: '/applications', position: 30, section: 'information' },
|
||||
{ name: 'Knowledge Base', icon: 'book-open', route: '/knowledgebase', position: 35 },
|
||||
{ name: 'Reports', icon: 'bar-chart-3', route: '/reports', position: 40 },
|
||||
{ name: 'Dashboard', icon: 'layout-dashboard', route: '/', position: 0, permission: 'assets.view' },
|
||||
{ name: 'Notifications', icon: 'bell', route: '/notifications', position: 5, permission: 'notifications.view' },
|
||||
{ name: 'Calendar', icon: 'calendar', route: '/calendar', position: 6, permission: 'notifications.view' },
|
||||
{ name: 'Map', icon: 'map', route: '/map', position: 4, permission: 'assets.view' },
|
||||
{ name: 'Machines', icon: 'cog', route: '/machines', position: 10, section: 'assets', permission: 'machines.view' },
|
||||
{ name: 'PCs', icon: 'desktop', route: '/pcs', position: 15, section: 'assets', permission: 'computers.view' },
|
||||
{ name: 'Network', icon: 'network-wired', route: '/network', position: 18, section: 'assets', permission: 'network.view' },
|
||||
{ name: 'Printers', icon: 'printer', route: '/printers', position: 20, section: 'assets', permission: 'printers.view' },
|
||||
{ name: 'USB Devices', icon: 'usb', route: '/usb', position: 25, section: 'assets', permission: 'usb.view' },
|
||||
{ name: 'Applications', icon: 'app-window', route: '/applications', position: 30, section: 'information', permission: 'applications.view' },
|
||||
{ name: 'Knowledge Base', icon: 'book-open', route: '/knowledgebase', position: 35, section: 'information', permission: 'kb.view' },
|
||||
{ name: 'Reports', icon: 'bar-chart-3', route: '/reports', position: 40, section: 'information', permission: 'reports.view' },
|
||||
]
|
||||
|
||||
// The canonical sidebar sections, in the order they render. A nav item DECLARES
|
||||
// which one it belongs to; nothing is inferred.
|
||||
//
|
||||
// Sections used to be derived from the position number - 10-29 became Assets,
|
||||
// 30-49 became Information - so a plugin landed in whichever bucket its number
|
||||
// happened to fall in. That is how USB Devices, an asset, came to sit under
|
||||
// Information next to GE-Enforce and Tech Tools, and why two plugins picking
|
||||
// the same number silently reordered each other. A number is for ORDER; it was
|
||||
// never a statement about meaning.
|
||||
//
|
||||
// An item with no section (or an unknown one) stays in the unlabelled group at
|
||||
// the top, which is the safe default: it appears, it is just not filed.
|
||||
const NAV_SECTIONS = [
|
||||
{ key: null, label: null },
|
||||
{ key: 'assets', label: 'Assets' },
|
||||
{ key: 'information', label: 'Information' },
|
||||
{ key: 'fleet', label: 'Fleet' },
|
||||
]
|
||||
|
||||
function buildNavItems(items) {
|
||||
// Drop items the user cannot reach: a nav item may carry a `permission`
|
||||
// (e.g. Slides -> slides.manage); hide it from anyone who lacks it so the
|
||||
// link does not dead-end at the router guard. Admins hold every permission.
|
||||
const visible = items.filter(item =>
|
||||
!item.permission || authStore.hasPermission(item.permission))
|
||||
//
|
||||
// A signed-out visitor sees only what a plugin has declared guest-reachable.
|
||||
// Without this the sidebar offers a signed-out reader the whole app and every
|
||||
// link bounces them to the login page, which reads as a broken site rather
|
||||
// than as a closed door.
|
||||
// A nav item may name one permission or several; holding ANY of them is
|
||||
// enough. GE-Enforce and Slides need that: the page is reachable by a
|
||||
// manager and by a plain viewer, and they hold different strings.
|
||||
const holds = item => {
|
||||
if (!item.permission) return true
|
||||
const names = Array.isArray(item.permission) ? item.permission : [item.permission]
|
||||
return names.some(name => authStore.hasPermission(name))
|
||||
}
|
||||
// A signed-out visitor gets whatever the server sent, which is already
|
||||
// filtered to what the guest role can reach - hasPermission knows nothing
|
||||
// about guests, so asking it here would hide everything.
|
||||
const visible = authStore.isAuthenticated ? items.filter(holds) : items
|
||||
|
||||
// Sort by position
|
||||
const sorted = [...visible].sort((a, b) => (a.position || 99) - (b.position || 99))
|
||||
const known = new Set(NAV_SECTIONS.map(s => s.key).filter(Boolean))
|
||||
const bucket = item => {
|
||||
const declared = (item.section || '').toLowerCase()
|
||||
return known.has(declared) ? declared : null
|
||||
}
|
||||
|
||||
// Assign section headers based on position ranges
|
||||
const result = []
|
||||
let currentSection = null
|
||||
|
||||
for (const item of sorted) {
|
||||
let section = null
|
||||
if (item.position >= 10 && item.position < 30 && currentSection !== 'Assets') {
|
||||
section = 'Assets'
|
||||
currentSection = 'Assets'
|
||||
} else if (item.section === 'information' || (item.position >= 30 && item.position < 50 && currentSection !== 'Information')) {
|
||||
if (currentSection !== 'Information') {
|
||||
section = 'Information'
|
||||
currentSection = 'Information'
|
||||
}
|
||||
}
|
||||
|
||||
result.push({
|
||||
...item,
|
||||
section,
|
||||
iconComponent: iconMap[item.icon] || null
|
||||
for (const { key, label } of NAV_SECTIONS) {
|
||||
const inSection = visible
|
||||
.filter(item => bucket(item) === key)
|
||||
// ?? not ||: position 0 is a real position, and `0 || 99` is 99, which
|
||||
// sent Dashboard - the only item that uses 0 - to the end of its group.
|
||||
.sort((a, b) => (a.position ?? 99) - (b.position ?? 99))
|
||||
inSection.forEach((item, index) => {
|
||||
result.push({
|
||||
...item,
|
||||
// Only the first item in a section carries its heading.
|
||||
section: index === 0 ? label : null,
|
||||
iconComponent: iconMap[item.icon] || null,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -234,6 +270,11 @@ function buildNavItems(items) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Refresh the signed-in user (and their permissions) on every app load. The
|
||||
// login payload is cached in localStorage, so without this a permission
|
||||
// granted mid-session stays invisible until the person logs out and back in.
|
||||
if (authStore.isAuthenticated) authStore.fetchUser()
|
||||
|
||||
// Half-hourly: cheap, and it crosses midnight into the new week without a
|
||||
// reload on a screen nobody touches.
|
||||
fiscalWeekTimer = setInterval(() => { currentFiscalWeek.value = fiscalWeek() }, 30 * 60 * 1000)
|
||||
|
||||
@@ -43,12 +43,27 @@
|
||||
<h2>{{ currentReport.name }}</h2>
|
||||
<div class="report-actions">
|
||||
<button class="btn btn-secondary" @click="exportCSV">Export CSV</button>
|
||||
<EmailReportButton v-if="emailtable && canemail"
|
||||
:subject="currentReport.name"
|
||||
:columns="emailtable.columns" :rows="emailtable.rows" />
|
||||
<button class="btn btn-secondary" @click="clearReport">Back to Reports</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="reportError" class="error-message">{{ reportError }}</div>
|
||||
|
||||
<!-- Server-side filters this report accepts (see reportFilterFields) -->
|
||||
<div v-if="activeFilterFields.length" class="filters">
|
||||
<select
|
||||
v-if="activeFilterFields.includes('period')"
|
||||
v-model="filterState.period"
|
||||
class="form-control"
|
||||
@change="refreshReport"
|
||||
>
|
||||
<option v-for="option in PERIOD_OPTIONS" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
v-if="activeFilterFields.includes('businessunit')"
|
||||
v-model="filterState.businessunitid"
|
||||
@@ -209,6 +224,39 @@
|
||||
</div>
|
||||
|
||||
<!-- Generic fallback -->
|
||||
<!--
|
||||
Any report that returns {columns, rows} renders as a table. Every core
|
||||
report above has a hand-written block; a plugin report had none, so it
|
||||
fell through to a raw JSON dump - which is what the printedparts reports
|
||||
have been showing since they were added. The shape is already the
|
||||
convention on the server side, so honouring it here means a plugin gets
|
||||
a real report with no core code at all.
|
||||
-->
|
||||
<div v-else-if="genericRows" class="report-content">
|
||||
<p v-if="reportData.summary" class="report-summary">
|
||||
<span v-for="(value, key) in reportData.summary" :key="key" class="summary-figure">
|
||||
<strong>{{ value }}</strong> {{ titleCase(key) }}
|
||||
</span>
|
||||
</p>
|
||||
<p v-else class="report-summary">{{ genericRows.length }} row(s)</p>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th v-for="column in genericColumns" :key="column">{{ titleCase(column) }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in genericRows" :key="index">
|
||||
<td v-for="column in genericColumns" :key="column">{{ row[column] }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-if="!genericRows.length" class="report-empty">
|
||||
Nothing in this period.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Last resort: a shape nothing here knows. Better visible than blank. -->
|
||||
<div v-else class="report-content">
|
||||
<pre>{{ JSON.stringify(reportData, null, 2) }}</pre>
|
||||
</div>
|
||||
@@ -219,11 +267,20 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { reportsApi, businessunitsApi, assetsApi, locationsApi, applicationsApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import api, { reportsApi, businessunitsApi, assetsApi, locationsApi, applicationsApi } from '@/api'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
import EmailReportButton from '@/components/EmailReportButton.vue'
|
||||
import { emailTableFrom, titleCase } from '@/utils/reportTable'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Emailing a report is the reports.export permission, the same one the endpoint
|
||||
// enforces. Offering the button to somebody who will only get a 403 is worse
|
||||
// than not offering it.
|
||||
const canemail = computed(() => authStore.hasPermission('reports.export'))
|
||||
|
||||
const reports = ref([])
|
||||
const search = ref('')
|
||||
@@ -240,18 +297,55 @@ const reportFilterFields = {
|
||||
'assets-by-status': ['assettype', 'businessunit'],
|
||||
'asset-inventory': ['businessunit', 'location'],
|
||||
'kb-popularity': ['limit'],
|
||||
'software-compliance': ['application']
|
||||
'software-compliance': ['application'],
|
||||
// Time-windowed reports. The endpoints already accepted a window; there was
|
||||
// simply no way to choose one from the page.
|
||||
'usb-audit': ['period'],
|
||||
'printedparts-consumption': ['period'],
|
||||
'printedparts-by-person': ['period']
|
||||
}
|
||||
|
||||
const filterState = ref({ businessunitid: '', assettypeid: '', locationid: '', appid: '', limit: '' })
|
||||
// Named windows rather than a day count: the question is "this shift" or "this
|
||||
// year", and making somebody work out that a year is 365 serves nobody.
|
||||
const PERIOD_OPTIONS = [
|
||||
{ value: 'day', label: 'Today' },
|
||||
{ value: 'month', label: 'Last month' },
|
||||
{ value: 'sixmonths', label: 'Last 6 months' },
|
||||
{ value: 'year', label: 'Last year' },
|
||||
{ value: 'all', label: 'All time' }
|
||||
]
|
||||
const PERIOD_DAYS = { day: 1, month: 30, sixmonths: 182, year: 365, all: 0 }
|
||||
|
||||
const filterState = ref({ businessunitid: '', assettypeid: '', locationid: '', appid: '', limit: '', period: 'month' })
|
||||
const reportError = ref('')
|
||||
const filterOptions = ref({ businessunits: [], assettypes: [], locations: [], applications: [] })
|
||||
|
||||
// A report is "generic" when it returns rows plus the columns to show them in.
|
||||
const genericRows = computed(() => {
|
||||
const data = reportData.value
|
||||
return data && Array.isArray(data.rows) ? data.rows : null
|
||||
})
|
||||
const genericColumns = computed(() => {
|
||||
const data = reportData.value
|
||||
if (!data) return []
|
||||
if (Array.isArray(data.columns) && data.columns.length) return data.columns
|
||||
// No declared columns: take them from the first row so the table still works.
|
||||
return data.rows?.length ? Object.keys(data.rows[0]) : []
|
||||
})
|
||||
|
||||
// The table to email, built from whatever the open report returned. Emailing
|
||||
// was available on two pages and nowhere else, purely because those two were
|
||||
// built as their own routed pages - the endpoint has always taken any
|
||||
// {columns, rows}. Lives in its own module so the shapes can be tested.
|
||||
const emailtable = computed(() => emailTableFrom(reportData.value))
|
||||
|
||||
const activeFilterFields = computed(() =>
|
||||
currentReport.value ? (reportFilterFields[currentReport.value.id] || []) : []
|
||||
)
|
||||
|
||||
function resetFilters() {
|
||||
filterState.value = { businessunitid: '', assettypeid: '', locationid: '', appid: '', limit: '' }
|
||||
filterState.value = { businessunitid: '', assettypeid: '', locationid: '', appid: '', limit: '', period: 'month' }
|
||||
reportError.value = ''
|
||||
}
|
||||
|
||||
// fetch dropdown options once, only for the fields the open report needs
|
||||
@@ -290,6 +384,11 @@ function filterParams() {
|
||||
if (state.locationid) params.locationid = state.locationid
|
||||
if (state.appid) params.appid = state.appid
|
||||
if (state.limit) params.limit = state.limit
|
||||
if (activeFilterFields.value.includes('period') && state.period) {
|
||||
params.period = state.period
|
||||
// Sent as well so an endpoint that only knows ?days= still honours it.
|
||||
params.days = PERIOD_DAYS[state.period]
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
@@ -312,12 +411,6 @@ async function loadReports() {
|
||||
}
|
||||
}
|
||||
|
||||
function titleCase(text) {
|
||||
return String(text || '')
|
||||
.split(/[\s_-]+/)
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
// Filter by name/description/category, then group by category in the fixed order.
|
||||
const groupedReports = computed(() => {
|
||||
@@ -386,44 +479,64 @@ function applyQuery(id) {
|
||||
async function runReport(report) {
|
||||
currentReport.value = report
|
||||
loading.value = true
|
||||
reportError.value = ''
|
||||
reportData.value = null
|
||||
window.scrollTo(0, 0)
|
||||
|
||||
const params = filterParams()
|
||||
try {
|
||||
let response
|
||||
switch (report.id) {
|
||||
case 'machines-by-type':
|
||||
response = await reportsApi.machinesByType(params)
|
||||
break
|
||||
case 'assets-by-status':
|
||||
response = await reportsApi.assetsByStatus(params)
|
||||
break
|
||||
case 'kb-popularity':
|
||||
response = await reportsApi.kbPopularity(params)
|
||||
break
|
||||
case 'software-compliance':
|
||||
response = await reportsApi.softwareCompliance(params)
|
||||
break
|
||||
case 'asset-inventory':
|
||||
response = await reportsApi.assetInventory(params)
|
||||
break
|
||||
default:
|
||||
console.error('Unknown report:', report.id)
|
||||
return
|
||||
}
|
||||
// Any report that declares an endpoint is fetched from it.
|
||||
//
|
||||
// This used to be a switch over five hardcoded core ids with
|
||||
// `default: console.error('Unknown report')`. A plugin could register a
|
||||
// report, have it appear in the catalogue, and then render nothing at all
|
||||
// when opened - the three printedparts reports were in exactly that state.
|
||||
// The endpoint is already on the contract; using it means a plugin never
|
||||
// needs a line of core code to be runnable.
|
||||
const url = report.endpoint || `/api/reports/${report.id}`
|
||||
const response = await api.get(stripApiPrefix(url), { params })
|
||||
reportData.value = response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error running report:', error)
|
||||
reportError.value = apiError(error, 'Could not run this report')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function exportCSV() {
|
||||
// The api client is already mounted at /api, so an endpoint from the contract
|
||||
// (which carries the prefix, because other consumers call it absolutely) has to
|
||||
// have it removed rather than doubled.
|
||||
function stripApiPrefix (url) {
|
||||
return url.startsWith('/api/') ? url.slice(4) : url
|
||||
}
|
||||
|
||||
async function exportCSV() {
|
||||
if (!currentReport.value) return
|
||||
const params = new URLSearchParams({ format: 'csv', ...filterParams() })
|
||||
window.open(withBase(`/api/reports/${currentReport.value.id}?${params}`), '_blank')
|
||||
// Same endpoint the report itself came from - a plugin report used to export
|
||||
// from /api/reports/<id>, which does not exist for it.
|
||||
//
|
||||
// Fetched through the api client, not window.open: a new window carries no
|
||||
// Authorization header, so any report behind a real jwt_required (every
|
||||
// plugin one) answered the export button with a 401 page. The core reports
|
||||
// are jwt-optional, which is the only reason window.open ever appeared to
|
||||
// work.
|
||||
const url = currentReport.value.endpoint || `/api/reports/${currentReport.value.id}`
|
||||
reportError.value = ''
|
||||
try {
|
||||
const response = await api.get(stripApiPrefix(url), {
|
||||
params: { format: 'csv', ...filterParams() },
|
||||
responseType: 'blob'
|
||||
})
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(response.data)
|
||||
link.download = `${currentReport.value.id}.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(link.href)
|
||||
} catch (error) {
|
||||
console.error('Error exporting report:', error)
|
||||
reportError.value = apiError(error, 'Could not export this report')
|
||||
}
|
||||
}
|
||||
|
||||
function clearReport() {
|
||||
@@ -433,6 +546,10 @@ function clearReport() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.summary-figure { margin-right: 1.25rem; }
|
||||
.table-scroll { overflow-x: auto; }
|
||||
.report-empty { color: var(--text-light); margin-top: 0.75rem; }
|
||||
|
||||
.report-group {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
@@ -225,6 +225,17 @@
|
||||
<p class="text-muted" style="font-size: 0.85rem; margin: 0 0 0.5rem 0">
|
||||
Select which actions this role can perform
|
||||
</p>
|
||||
<!-- The guest role is everyone who is not signed in, so what it may
|
||||
hold is bounded: reads, plus the few writes a plugin has
|
||||
declared safe to do anonymously. The rest are disabled here
|
||||
rather than refused on save, so the limit is visible while the
|
||||
decision is being made. -->
|
||||
<p v-if="editingRole?.isguest" class="guest-note">
|
||||
This role is <strong>everyone who is not signed in</strong>.
|
||||
Anything ticked here is readable by anybody who can reach this
|
||||
site, with no account and no name in the audit log. Greyed-out
|
||||
permissions cannot be given to guests at all.
|
||||
</p>
|
||||
<div class="permissions-grid">
|
||||
<div v-for="(perms, category) in permissionsGrouped" :key="category" class="permission-category">
|
||||
<div class="category-header">
|
||||
@@ -239,10 +250,14 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="category-perms">
|
||||
<label v-for="p in perms" :key="p.name" class="checkbox-label perm-item">
|
||||
<label v-for="p in perms" :key="p.name" class="checkbox-label perm-item"
|
||||
:class="{ 'perm-blocked': !mayGrant(p.name) }"
|
||||
:title="mayGrant(p.name) ? null
|
||||
: 'A guest cannot be given this. Reading is grantable; a write has to be declared safe to do without an account.'">
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="p.name"
|
||||
:disabled="!mayGrant(p.name)"
|
||||
v-model="roleForm.permissions"
|
||||
>
|
||||
{{ p.description }}
|
||||
@@ -382,19 +397,33 @@ function formatCategory(cat) {
|
||||
return cat.charAt(0).toUpperCase() + cat.slice(1)
|
||||
}
|
||||
|
||||
// Which permissions this role is allowed to hold at all. Only the guest role
|
||||
// is bounded; the API sends the list it will accept, so the grid and the server
|
||||
// cannot disagree about it.
|
||||
function mayGrant(name) {
|
||||
if (!editingRole.value?.isguest) return true
|
||||
const grantable = editingRole.value?.grantable
|
||||
return Array.isArray(grantable) ? grantable.includes(name) : false
|
||||
}
|
||||
|
||||
function isCategoryFullySelected(category) {
|
||||
const catPerms = permissionsGrouped.value[category] || []
|
||||
const catPerms = (permissionsGrouped.value[category] || [])
|
||||
.filter(p => mayGrant(p.name))
|
||||
return catPerms.length > 0 && catPerms.every(p => roleForm.permissions.includes(p.name))
|
||||
}
|
||||
|
||||
function isCategoryPartiallySelected(category) {
|
||||
const catPerms = permissionsGrouped.value[category] || []
|
||||
const catPerms = (permissionsGrouped.value[category] || [])
|
||||
.filter(p => mayGrant(p.name))
|
||||
const selected = catPerms.filter(p => roleForm.permissions.includes(p.name))
|
||||
return selected.length > 0 && selected.length < catPerms.length
|
||||
}
|
||||
|
||||
function toggleCategory(category, checked) {
|
||||
const catPerms = permissionsGrouped.value[category] || []
|
||||
// Ticking a whole category must not try to grant a guest something the API
|
||||
// will refuse - the header checkbox would then look broken.
|
||||
const catPerms = (permissionsGrouped.value[category] || [])
|
||||
.filter(p => mayGrant(p.name))
|
||||
const permNames = catPerms.map(p => p.name)
|
||||
|
||||
if (checked) {
|
||||
@@ -780,4 +809,13 @@ onMounted(loadData)
|
||||
font-size: 0.85rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.perm-blocked { opacity: 0.45; cursor: not-allowed; }
|
||||
.guest-note {
|
||||
font-size: 0.85rem;
|
||||
color: var(--warning);
|
||||
border-left: 3px solid var(--warning);
|
||||
padding-left: 0.6rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user