Files
shopdb-flask/plugins/applications/frontend/views/ApplicationsList.vue
benjamin.baker 79545db96e Add a Delete action to the applications list and withdraw the app policy
The Applications page had no delete control even though the endpoint and
the API client both already existed, so the gap was UI-only.

The button is gated on applications.delete via hasPermission rather than
on the admin role, mirroring the backend guard: admins bypass it anyway,
and a non-admin role granted the permission should get the button instead
of a control that would only 403.

Deleting also withdraws the generated install policy. Delete here is a
soft delete, and the policy is a separate row that did not follow, so a
deactivated application kept installing itself across the fleet with
nothing on screen to explain why. A customised policy is still left
alone, on the same ownership terms as unticking the box.
2026-09-07 13:36:06 -04:00

263 lines
7.8 KiB
Vue

<template>
<div>
<div class="page-header">
<h2>Applications</h2>
<router-link to="/applications/new" class="btn btn-primary">Add Application</router-link>
</div>
<!-- Filters -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search applications..."
@input="debouncedSearch"
/>
<select v-model="filter" class="form-control" @change="applyFilter">
<option value="installable">Installable Applications</option>
<option value="all">All Applications</option>
<option value="hidden">Hidden Applications</option>
</select>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th style="width: 50px;">Files</th>
<th style="width: 50px;">Docs</th>
<th>Application Name</th>
<th>Description</th>
<th>Support Team</th>
<th>Contacts</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="app in applications" :key="app.appid" class="clickable-row" @click="$router.push(`/applications/${app.appid}`)">
<td class="icon-cell">
<a v-if="app.installpath" :href="fileHref(app.installpath)" target="_blank" title="Download Installation Files" class="icon-download">
&#x2B07;
</a>
<a v-else-if="app.applicationlink" :href="fileHref(app.applicationlink)" target="_blank" title="Application Link" class="icon-link">
&#x1F517;
</a>
</td>
<td class="icon-cell">
<a v-if="app.documentationpath" :href="fileHref(app.documentationpath)" target="_blank" title="View Documentation" class="icon-docs">
&#x1F4C4;
</a>
</td>
<td>
<router-link :to="`/applications/${app.appid}`">
{{ app.appname }}
</router-link>
<span class="app-flags">
<span v-if="app.isinstallable" class="badge badge-info badge-sm">Installable</span>
<span v-if="app.islicenced" class="badge badge-warning badge-sm">Licensed</span>
<span v-if="app.isprinter" class="badge badge-secondary badge-sm">Printer</span>
</span>
</td>
<td class="description">{{ app.appdescription || '-' }}</td>
<td>{{ app.supportteamname || '-' }}</td>
<td>{{ app.contacts && app.contacts.length ? app.contacts.map(c => c.name).join(', ') : '-' }}</td>
<td class="actions" @click.stop>
<router-link
:to="`/applications/${app.appid}`"
class="btn btn-secondary btn-sm"
>
View
</router-link>
<button
v-if="candelete"
class="btn btn-danger btn-sm"
@click="deleteApplication(app)"
>
Delete
</button>
</td>
</tr>
<tr v-if="applications.length === 0">
<td colspan="7" style="text-align: center; color: var(--text-light);">
No applications found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { applicationsApi } from '@/api'
import { useAuthStore } from '@/stores/auth'
import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
import { fileHref } from '@/utils/basePath'
const applications = ref([])
const loading = ref(true)
// Mirrors the backend guard on the endpoint rather than checking for the admin
// role, so a non-admin role granted applications.delete gets the button too and
// nobody is shown a control that would only 403.
const authStore = useAuthStore()
const candelete = computed(() => authStore.hasPermission('applications.delete'))
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadApplications })
const filter = ref('installable')
const totalPages = ref(1)
const perPage = ref(20)
let searchTimeout = null
onMounted(() => {
loadApplications()
})
// A filter change must go back to page 1. Selecting a filter while on page 5
// asked the server for page 5 of a result set that now has one page, and the
// list came back empty as though the filter matched nothing.
//
// setPage(1) writes the URL, which the composable's watcher picks up and
// answers with onChange - so calling the loader as well would fetch twice.
// Load directly only when already on page 1, where nothing changes and the
// watcher stays silent.
function applyFilter() {
if (page.value > 1) setPage(1)
else loadApplications()
}
async function loadApplications() {
loading.value = true
try {
const params = {
page: page.value,
perpage: perPage.value,
search: search.value || undefined
}
// Apply filter
if (filter.value === 'installable') {
params.installable = true
} else if (filter.value === 'hidden') {
params.hidden = true
}
const response = await applicationsApi.list(params)
applications.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading applications:', error)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
setSearch(search.value)
loadApplications()
}, 300)
}
// The server deactivates rather than drops the row, so this is reversible and
// the prompt does not claim otherwise. It does warn about the fleet-wide
// consequence, which is the part that is not obvious from a Delete button.
async function deleteApplication(app) {
const warning = app.isrequired
? `\n\n"${app.appname}" is currently required on all PCs. Removing it also `
+ 'withdraws the policy that installs it.'
: ''
if (!confirm(`Remove "${app.appname}" from the catalogue?${warning}`)) return
try {
await applicationsApi.delete(app.appid)
loadApplications()
} catch (error) {
console.error('Error deleting application:', error)
alert(error.response?.data?.data?.error?.message
|| 'Failed to remove the application.')
}
}
function goToPage(p) {
setPage(p)
loadApplications()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
setPage(1)
loadApplications()
}
</script>
<style scoped>
/* Application list specific styles */
.icon-cell {
text-align: center;
width: 50px;
}
.icon-cell a {
font-size: 1.25rem;
text-decoration: none;
}
.icon-download {
color: var(--success);
}
.icon-link {
color: var(--link);
}
.icon-docs {
color: var(--secondary);
}
.icon-cell a:hover {
opacity: 0.7;
}
.app-flags {
display: inline-flex;
gap: 0.375rem;
flex-wrap: wrap;
margin-left: 0.5rem;
vertical-align: middle;
}
.badge-sm {
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
}
.description {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-light);
}
</style>