Both kiosks admit a check-in or check-out with no login when the caller sits in a configured network. The subtraction that makes that safe was missing. A reverse proxy normally sits ON the plant network a site enters in the allowlist. When the proxy forwards nothing - misconfigured, or something bypassed it - the request arrives wearing the proxy's own address, which matches the allowlist, while carrying no evidence whatever about who sent it. That is the one case that has to refuse, and it was being admitted. The previous fail-closed test only looked closed because the proxy in it happened to sit outside the range. Addresses that belong to the path are now subtracted before the allowlist is consulted: loopback always, since no site's kiosk is the server itself, plus whatever the site declares as its proxies. USB and printedparts get the same treatment - they had diverged for no reason anybody chose. Three more, same surface: A range that admits everybody can no longer be saved by accident. 0.0.0.0/0 and ::/0 are refused outright, anything wider than a site plausibly needs is refused unless an explicit override is set, and the refusal says why. The kiosk token no longer travels in a URL. The provisioning link carries a single-use setup code redeemed on first open, so the token stays out of browser history, referrers and proxy logs. The route is declared public deliberately: the secret is the code, the way a password-reset link is the secret. The badge-to-name lookup is bounded. It answers without a login and is now reachable from a whole network rather than one screen, so a source that keeps missing gets refused rather than being allowed to walk the directory. On by default, budget and kill switch are settings. All four new settings carry controls on their settings pages. A setting that can stop the kiosk, or widen it, must not be reachable only by hand-editing rows. The installer's client-IP advice is corrected: it described the opposite of what the shipped waitress flags do, and named only GE-Enforce as the thing that breaks when the rule is absent, when the two kiosk allowlists depend on it too.
331 lines
12 KiB
Vue
331 lines
12 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>3D Printed Parts</h2>
|
|
</div>
|
|
|
|
<div class="card form-card">
|
|
<div v-if="message" class="settings-success">{{ message }}</div>
|
|
<div v-if="error" class="error-message">{{ error }}</div>
|
|
|
|
<div class="form-group">
|
|
<label>Item code prefix</label>
|
|
<input v-model="values.printedparts_code_prefix" type="text"
|
|
class="form-control" maxlength="8" />
|
|
<p class="field-hint">
|
|
New items mint codes like {{ values.printedparts_code_prefix || '3DP' }}0042.
|
|
Changing it does not rename existing items.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Kiosk label prefix</label>
|
|
<input v-model="values.printedparts_label_prefix" type="text"
|
|
class="form-control" maxlength="8" placeholder="none" />
|
|
<p class="field-hint">
|
|
Leading text on this site's physical labels, shown at the kiosk before the
|
|
number box so operators type only the digits. Leave it empty to show no
|
|
prefix. This is about the printed tags, not the item codes above.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Default low-stock threshold</label>
|
|
<input v-model.number="values.printedparts_default_threshold"
|
|
type="number" min="0" class="form-control" />
|
|
<p class="field-hint">Seed value for new items; each item can override.</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" v-model="kioskEnabled" />
|
|
Serve the parts kiosk
|
|
</label>
|
|
<p class="field-hint">
|
|
Off by default. The kiosk routes are the only ones that write without
|
|
a login, so a site opts in.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Kiosk device token</label>
|
|
<div class="token-row">
|
|
<input v-model="values.printedparts_kiosk_token" type="text"
|
|
class="form-control" placeholder="not set" />
|
|
<button type="button" class="btn btn-secondary" @click="generateToken">
|
|
Generate
|
|
</button>
|
|
<button type="button" class="btn btn-secondary" @click="copyLink">
|
|
Copy setup link
|
|
</button>
|
|
</div>
|
|
<p v-if="linkMessage" class="field-hint">{{ linkMessage }}</p>
|
|
<input v-if="link" :value="link" type="text" class="form-control"
|
|
readonly @focus="$event.target.select()" />
|
|
<p class="field-hint">
|
|
Each kiosk screen is provisioned once by opening
|
|
<code>/parts-kiosk?token=<this value></code> on it. The browser
|
|
keeps it; the address bar does not. A screen without it gets a 404,
|
|
and so does anyone else who finds the route.
|
|
</p>
|
|
<p v-if="tokenJustGenerated" class="field-hint token-warn">
|
|
Copy this now and save. It is masked once stored, so this is the only
|
|
time it is shown.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Networks allowed without the token</label>
|
|
<NetworkAllowlistPicker v-model="values.printedparts_kiosk_allowed_cidrs" />
|
|
<p class="field-hint">
|
|
For people who open the kiosk on their own PC rather than at the
|
|
screen. A browser on one of these networks is admitted with no token
|
|
and no login, so keep this to the shop-floor ranges. Leave empty to
|
|
require the token from every caller. Separate from the USB kiosk
|
|
allowlist: one does not grant the other.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Reverse proxies in front of this server</label>
|
|
<NetworkAllowlistPicker v-model="values.printedparts_kiosk_trusted_proxies" />
|
|
<p class="field-hint">
|
|
Only needed when a proxy sits between the kiosk and this server. A
|
|
request arriving FROM one of these addresses is never treated as a
|
|
kiosk, so a proxy inside an allowed network above cannot admit itself.
|
|
Loopback is always excluded and needs no entry. Leave empty when
|
|
browsers reach this server directly.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Unknown badge at the kiosk</label>
|
|
<select v-model="values.printedparts_unknown_badge" class="form-control">
|
|
<option value="deny">Deny - refuse badges with no directory match</option>
|
|
<option value="allow">Allow - record the SSO with no name</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Alert shopdb users</label>
|
|
<div class="user-picker">
|
|
<label v-for="candidate in users" :key="candidate.userid" class="user-row">
|
|
<input type="checkbox" :value="String(candidate.userid)"
|
|
v-model="selectedUserids" />
|
|
<span>{{ candidate.username }}</span>
|
|
<span class="user-email">{{ candidate.email }}</span>
|
|
</label>
|
|
<p v-if="users.length === 0" class="field-hint">No users loaded</p>
|
|
</div>
|
|
<p class="field-hint">
|
|
Selected users receive low-stock alerts at their account email.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Alert roles</label>
|
|
<div class="user-picker">
|
|
<label v-for="role in roles" :key="role.roleid" class="user-row">
|
|
<input type="checkbox" :value="String(role.roleid)"
|
|
v-model="selectedRoleids" />
|
|
<span>{{ role.rolename }}</span>
|
|
<span class="user-email">{{ role.description }}</span>
|
|
</label>
|
|
<p v-if="roles.length === 0" class="field-hint">No roles loaded</p>
|
|
</div>
|
|
<p class="field-hint">
|
|
Every active member of a selected role receives low-stock alerts.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Additional alert emails</label>
|
|
<input v-model="values.printedparts_alert_email" type="text"
|
|
class="form-control" placeholder="parts-team@example.com, lead@example.com" />
|
|
<p class="field-hint">
|
|
Comma-separated. Empty uses the site-wide alert recipients
|
|
(Settings > System > Email). Alerts fire once when an item
|
|
crosses its threshold; restocking above re-arms.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Alert support team (Teams webhook)</label>
|
|
<select v-model="values.printedparts_alert_supportteamid" class="form-control">
|
|
<option value="">Site default webhook</option>
|
|
<option v-for="team in supportTeams" :key="team.supportteamid" :value="String(team.supportteamid)">
|
|
{{ team.teamname }}{{ team.webhookurl ? '' : ' (no webhook set)' }}
|
|
</option>
|
|
</select>
|
|
<p class="field-hint">
|
|
Low-stock alerts post to this team's webhook (set on Settings >
|
|
Support Teams). Empty uses the site-wide alert webhook.
|
|
</p>
|
|
</div>
|
|
|
|
<button class="btn btn-primary" :disabled="saving" @click="save">
|
|
{{ saving ? 'Saving...' : 'Save' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { settingsApi, usersApi, supportteamsApi } from '@/api'
|
|
import NetworkAllowlistPicker from '@/components/NetworkAllowlistPicker.vue'
|
|
import api from '@/api'
|
|
import { withBase } from '@/utils/basePath'
|
|
|
|
// The settings API masks the token, so the field shows '********'. Copying that
|
|
// provisions a screen with the mask and every kiosk call answers 404 with
|
|
// nothing to say why. Ask the server for the real link instead.
|
|
const link = ref('')
|
|
const linkMessage = ref('')
|
|
|
|
async function copyLink () {
|
|
linkMessage.value = ''
|
|
link.value = ''
|
|
try {
|
|
const response = await api.get('/printedparts/kiosk/provisioning-link')
|
|
const url = window.location.origin + withBase(response.data.data.url)
|
|
link.value = url
|
|
try {
|
|
await navigator.clipboard.writeText(url)
|
|
linkMessage.value = 'Copied. Open this once on the kiosk screen.'
|
|
} catch (clipboardError) {
|
|
linkMessage.value = 'Copy this and open it once on the kiosk screen.'
|
|
}
|
|
} catch (linkError) {
|
|
linkMessage.value = linkError.response?.data?.data?.error?.message
|
|
|| 'Could not build the setup link.'
|
|
}
|
|
}
|
|
|
|
const KEYS = [
|
|
'printedparts_code_prefix',
|
|
'printedparts_label_prefix',
|
|
'printedparts_default_threshold',
|
|
'printedparts_kiosk_enabled',
|
|
'printedparts_kiosk_token',
|
|
'printedparts_kiosk_allowed_cidrs',
|
|
'printedparts_kiosk_trusted_proxies',
|
|
'printedparts_unknown_badge',
|
|
'printedparts_alert_email',
|
|
'printedparts_alert_userids',
|
|
'printedparts_alert_roleids',
|
|
'printedparts_alert_supportteamid'
|
|
]
|
|
|
|
const values = ref({
|
|
printedparts_code_prefix: '3DP',
|
|
printedparts_label_prefix: '',
|
|
printedparts_default_threshold: 5,
|
|
printedparts_kiosk_enabled: 'false',
|
|
printedparts_kiosk_token: '',
|
|
printedparts_kiosk_allowed_cidrs: '',
|
|
printedparts_kiosk_trusted_proxies: '',
|
|
printedparts_unknown_badge: 'deny',
|
|
printedparts_alert_email: '',
|
|
printedparts_alert_userids: '',
|
|
printedparts_alert_roleids: '',
|
|
printedparts_alert_supportteamid: ''
|
|
})
|
|
|
|
// Settings are stored as strings. Bridge the checkbox to that rather than
|
|
// letting a boolean reach the API and come back as the string 'True'.
|
|
const kioskEnabled = computed({
|
|
get: () => String(values.value.printedparts_kiosk_enabled).toLowerCase() === 'true',
|
|
set: (on) => { values.value.printedparts_kiosk_enabled = on ? 'true' : 'false' },
|
|
})
|
|
|
|
const tokenJustGenerated = ref(false)
|
|
|
|
// A token an operator would not guess and does not have to invent. Generated in
|
|
// the browser so it never travels anywhere it did not need to.
|
|
function generateToken() {
|
|
const bytes = new Uint8Array(24)
|
|
crypto.getRandomValues(bytes)
|
|
values.value.printedparts_kiosk_token =
|
|
Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
|
|
tokenJustGenerated.value = true
|
|
}
|
|
const supportTeams = ref([])
|
|
const users = ref([])
|
|
const selectedUserids = ref([])
|
|
const roles = ref([])
|
|
const selectedRoleids = ref([])
|
|
const saving = ref(false)
|
|
const message = ref('')
|
|
const error = ref('')
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
const response = await settingsApi.list({ category: 'printedparts' })
|
|
const rows = response.data.data || []
|
|
for (const row of rows) {
|
|
if (KEYS.includes(row.key)) values.value[row.key] = row.value
|
|
}
|
|
values.value.printedparts_default_threshold =
|
|
parseInt(values.value.printedparts_default_threshold, 10) || 0
|
|
selectedUserids.value = (values.value.printedparts_alert_userids || '')
|
|
.split(',').map(id => id.trim()).filter(Boolean)
|
|
const usersResponse = await usersApi.list()
|
|
users.value = (usersResponse.data.data || []).filter(
|
|
candidate => candidate.isactive && candidate.email)
|
|
selectedRoleids.value = (values.value.printedparts_alert_roleids || '')
|
|
.split(',').map(id => id.trim()).filter(Boolean)
|
|
const rolesResponse = await usersApi.roles.list()
|
|
roles.value = rolesResponse.data.data || []
|
|
const teamsResponse = await supportteamsApi.list()
|
|
supportTeams.value = teamsResponse.data.data || []
|
|
} catch (loadError) {
|
|
error.value = 'Could not load settings'
|
|
console.error(loadError)
|
|
}
|
|
})
|
|
|
|
async function save() {
|
|
saving.value = true
|
|
message.value = ''
|
|
error.value = ''
|
|
try {
|
|
values.value.printedparts_alert_userids = selectedUserids.value.join(',')
|
|
values.value.printedparts_alert_roleids = selectedRoleids.value.join(',')
|
|
for (const key of KEYS) {
|
|
await settingsApi.update(key, String(values.value[key] ?? ''))
|
|
}
|
|
message.value = 'Settings saved'
|
|
} catch (saveError) {
|
|
error.value = saveError.response?.data?.data?.error?.message || 'Save failed'
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.token-row { display: flex; gap: 8px; align-items: center; }
|
|
.token-row .form-control { flex: 1 1 auto; }
|
|
.token-warn { color: var(--warning-dark); }
|
|
|
|
.field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; }
|
|
.user-picker {
|
|
max-height: 12rem;
|
|
overflow-y: auto;
|
|
border: 1px solid var(--border);
|
|
border-radius: 0.35rem;
|
|
padding: 0.5rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.35rem;
|
|
}
|
|
.user-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
cursor: pointer;
|
|
}
|
|
.user-email { color: var(--text-light); font-size: 0.85rem; }
|
|
</style>
|