--bg-card is deliberately translucent in dark mode (rgba(0,0,61,0.4)) so cards glass over the page; --bg-card-solid exists for the things that must not. Two hand-rolled modal panels used the former, leaving the notification-type and access-protocol editors transparent over the overlay with the table legible through them. The shared .modal in style.css already got this right. Also writes down the page-vs-modal rule the codebase already follows, since nothing stated it: a record with a detail page gets a routed form page, a lookup row that only exists inside its list gets a modal over that list. Plus the modal rules from the overlay-close fix - data entry never closes on a stray click, confirmations may, and panels are painted solid.
223 lines
6.7 KiB
Vue
223 lines
6.7 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h1>PC Access Protocols</h1>
|
|
<div class="actions">
|
|
<router-link to="/settings" class="btn btn-secondary">Back to Settings</router-link>
|
|
<button class="btn btn-primary" @click="openNew">New Protocol</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<p class="hint">
|
|
Remote-access protocols offered on PCs. Links are built as
|
|
<code>{{ '{scheme}://{hostname}.<pc_access_domain>:{port}' }}</code> from
|
|
each protocol's template. Set the domain in
|
|
<router-link to="/settings/site">Site & Facility</router-link>.
|
|
</p>
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Scheme</th>
|
|
<th>Default port</th>
|
|
<th>Link template</th>
|
|
<th>Active</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="p in protocols" :key="p.protocolid">
|
|
<td><strong>{{ p.name }}</strong></td>
|
|
<td class="mono">{{ p.scheme }}</td>
|
|
<td>{{ p.defaultport ?? '-' }}</td>
|
|
<td class="mono">{{ p.linktemplate }}</td>
|
|
<td>
|
|
<span class="badge" :class="p.isactive ? 'badge-success' : 'badge-secondary'">
|
|
{{ p.isactive ? 'yes' : 'no' }}
|
|
</span>
|
|
</td>
|
|
<td class="actions">
|
|
<button class="btn btn-sm btn-secondary" @click="openEdit(p)">Edit</button>
|
|
<button class="btn btn-sm btn-danger" @click="remove(p)">Delete</button>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="!loading && !protocols.length">
|
|
<td colspan="6" class="muted" style="text-align:center;">No protocols.</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="editing" class="modal-overlay">
|
|
<div class="modal-panel">
|
|
<h2>{{ form.protocolid ? 'Edit' : 'New' }} Protocol</h2>
|
|
<div class="form-grid">
|
|
<label class="field">
|
|
<span>Name</span>
|
|
<input v-model="form.name" type="text" maxlength="50" placeholder="e.g. VNC" />
|
|
</label>
|
|
<label class="field">
|
|
<span>Scheme</span>
|
|
<input v-model="form.scheme" type="text" maxlength="20" placeholder="vnc / rdp / https / ssh" />
|
|
</label>
|
|
<label class="field">
|
|
<span>Default port</span>
|
|
<input v-model.number="form.defaultport" type="number" min="1" max="65535" placeholder="5900" />
|
|
</label>
|
|
<label class="field">
|
|
<span>Link template</span>
|
|
<input v-model="form.linktemplate" type="text" maxlength="255" placeholder="vnc://{host}:{port}" />
|
|
<small class="muted">Placeholders: <code>{host}</code>, <code>{port}</code>, <code>{scheme}</code></small>
|
|
</label>
|
|
<label class="field checkbox">
|
|
<input v-model="form.isactive" type="checkbox" />
|
|
<span>Active</span>
|
|
</label>
|
|
</div>
|
|
<p v-if="error" class="error">{{ error }}</p>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-secondary" @click="close">Cancel</button>
|
|
<button class="btn btn-primary" :disabled="saving || !formValid" @click="save">
|
|
{{ saving ? 'Saving...' : 'Save' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { computersApi } from '@/api'
|
|
import { apiError } from '@/utils/apiError'
|
|
|
|
const protocols = ref([])
|
|
const loading = ref(true)
|
|
const editing = ref(false)
|
|
const saving = ref(false)
|
|
const error = ref('')
|
|
const form = ref({})
|
|
|
|
const formValid = computed(() =>
|
|
form.value.name && form.value.scheme && form.value.linktemplate
|
|
)
|
|
|
|
async function load() {
|
|
loading.value = true
|
|
try {
|
|
const response = await computersApi.protocols.list({ active: false })
|
|
protocols.value = response.data.data || []
|
|
} catch (err) {
|
|
console.error('Error loading protocols:', err)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function openNew() {
|
|
error.value = ''
|
|
form.value = { name: '', scheme: '', defaultport: null, linktemplate: '', isactive: true }
|
|
editing.value = true
|
|
}
|
|
|
|
function openEdit(p) {
|
|
error.value = ''
|
|
form.value = {
|
|
protocolid: p.protocolid,
|
|
name: p.name,
|
|
scheme: p.scheme,
|
|
defaultport: p.defaultport ?? null,
|
|
linktemplate: p.linktemplate,
|
|
isactive: p.isactive !== false
|
|
}
|
|
editing.value = true
|
|
}
|
|
|
|
function close() {
|
|
editing.value = false
|
|
}
|
|
|
|
async function save() {
|
|
saving.value = true
|
|
error.value = ''
|
|
try {
|
|
if (form.value.protocolid) {
|
|
await computersApi.protocols.update(form.value.protocolid, form.value)
|
|
} else {
|
|
await computersApi.protocols.create(form.value)
|
|
}
|
|
editing.value = false
|
|
await load()
|
|
} catch (err) {
|
|
error.value = apiError(err, 'Save failed.')
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
async function remove(p) {
|
|
if (!confirm(`Delete protocol "${p.name}"? (kept but deactivated if any PC uses it)`)) return
|
|
try {
|
|
await computersApi.protocols.remove(p.protocolid)
|
|
await load()
|
|
} catch (err) {
|
|
console.error('Error deleting protocol:', err)
|
|
}
|
|
}
|
|
|
|
onMounted(load)
|
|
</script>
|
|
|
|
<style scoped>
|
|
.hint {
|
|
color: var(--text-light);
|
|
font-size: 0.9rem;
|
|
margin: 0 0 14px;
|
|
}
|
|
.mono { font-family: monospace; font-size: 0.85rem; }
|
|
.muted { color: var(--text-light); }
|
|
|
|
.modal-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: center;
|
|
padding: 40px 16px;
|
|
overflow-y: auto;
|
|
z-index: 1000;
|
|
}
|
|
.modal-panel {
|
|
/* Solid: var(--bg-card) is translucent in dark mode, which made this
|
|
dialog see-through. */
|
|
background: var(--bg-card-solid);
|
|
color: var(--text);
|
|
border: 1px solid var(--border);
|
|
border-radius: 10px;
|
|
padding: 24px;
|
|
width: 100%;
|
|
max-width: 520px;
|
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
|
|
}
|
|
.modal-panel h2 { margin: 0 0 18px; }
|
|
.form-grid { display: flex; flex-direction: column; gap: 14px; }
|
|
.field { display: flex; flex-direction: column; gap: 4px; }
|
|
.field > span { font-size: 0.85rem; color: var(--text-light); }
|
|
.field input[type="text"],
|
|
.field input[type="number"] {
|
|
padding: 8px 10px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
}
|
|
.field.checkbox { flex-direction: row; align-items: center; gap: 8px; }
|
|
.field.checkbox > span { color: var(--text); font-size: 1rem; }
|
|
.error { color: var(--danger); margin: 12px 0 0; }
|
|
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
|
|
</style>
|