Files
shopdb-flask/frontend/src/views/settings/AuthenticationSettings.vue
cproudlock 534f179ee6 Make main the lean product a site receives
main was 16 days and 259 commits behind the line the product is actually built
on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It
was neither current nor curated - just stale. This brings it forward and, at the
same time, makes it mean something: main is what a receiving site installs, and
nothing else.

WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides,
knowledgebase, machines, network, notifications, printers, slides, tools,
warranty - plus applications, which is manifest-less core and always ships. The
only manifest dependency inside that set is endpoints on computers, and both are
in it, so the closure is self-contained.

WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb
stay on the site line. So do the Windows installer tree, the VM rehearsal
helpers, the agent tooling config, one site's data loader, the internal audit
and findings records, and the planner spreadsheet. 1,135 tracked files against
1,438.

A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped:

deploy/site-profile-universal.json still named all seventeen plugins. It is the
file docs/DEPLOY.md tells a site to provision from, so a fresh install would
have failed at `flask plugin apply-profile` with PluginError - the first thing a
receiving site does, broken by the branch meant to serve them. It now names the
twelve that exist, and its comment says the two must agree.

agent/ was never refreshed. Whoever populated this branch copied shopdb/,
plugins/, frontend/ and tests/ from the site line and left the agent tree at its
September state: 22 C# and PowerShell sources missing, including AgentCredentials,
EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the
site line had deleted still present. The old tree is internally consistent, so it
compiles, and nothing in CI builds it - every gate was green over a server that
speaks enrollment and per-PC credentials to a bundled agent source that cannot.
Resynced whole.

DECISIONS THIS ENCODES, rather than defers:

- The plugin-authoring lab builds printedparts, whose manifest depends on
  employees, so it could not stay. The lab remains on the site line and main
  ships no build-along tutorial. That is a real loss for an adopting developer
  and is recorded here rather than discovered later.
- Recognition, Training and Recertification notifications are no longer seeded.
  Each splits one notification into a card per employee and renders a photo,
  which needs a plugin this branch does not bundle. Seeding them would offer an
  author a type that cannot resolve anybody. A site that installs employees gets
  them from that plugin's own seed.
- Windows/IIS is documented as the legacy path throughout, and the README leads
  with Docker, because that is what a receiving site gets.

The API description is regenerated for THIS branch - 347 paths and 483
operations, down from 415 and 573 - so the published contract describes what is
here rather than what the site line serves. docs/CONFIG.md lost the two
measuring-tool label keys the code no longer declares, which two lanes had
decided opposite ways.

Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run
rather than skip, and every index line count is derived from the file it names.
2026-09-19 11:24:40 -04:00

291 lines
12 KiB
Vue

<template>
<div>
<div class="page-header">
<h2>Authentication</h2>
</div>
<div class="section-card">
<div class="setting-group">
<h3>SAML Single Sign-On</h3>
<p class="setting-notice">
<strong>Not implemented yet.</strong> These settings are stored so a
site can be configured ahead of the feature landing, but nothing
consumes them today: there is no SAML login route, and no assertion
is accepted. Sign-in is local accounts only, whatever is set here.
</p>
<p class="setting-description">
When it ships, users will sign in through your organization's identity
provider (Azure AD, Okta, etc.), with local login available as a
fallback.
</p>
<div class="setting-row">
<label class="toggle-label">
<span>Enable SAML SSO</span>
<button
class="toggle-btn"
:class="{ active: settings.saml_enabled }"
@click="toggleSetting('saml_enabled')"
:disabled="saving"
>
<span class="toggle-slider"></span>
</button>
</label>
</div>
<template v-if="settings.saml_enabled">
<div class="setting-row">
<label>
<span>Identity Provider Metadata URL</span>
<input
type="url"
v-model="settings.saml_idp_metadata_url"
placeholder="https://login.microsoftonline.com/.../federationmetadata.xml"
@blur="saveSetting('saml_idp_metadata_url', settings.saml_idp_metadata_url)"
:disabled="saving"
>
</label>
</div>
<div class="setting-row">
<label>
<span>Service Provider Entity ID</span>
<input
type="text"
v-model="settings.saml_entity_id"
placeholder="https://shopdb.example.com"
@blur="saveSetting('saml_entity_id', settings.saml_entity_id)"
:disabled="saving"
>
<small class="input-hint">Unique identifier for this application</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Assertion Consumer Service URL</span>
<input
type="url"
v-model="settings.saml_acs_url"
placeholder="https://shopdb.example.com/api/auth/saml/acs"
@blur="saveSetting('saml_acs_url', settings.saml_acs_url)"
:disabled="saving"
>
<small class="input-hint">URL where SAML responses are sent</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Admin Group Name</span>
<input
type="text"
v-model="settings.saml_admin_group"
placeholder="ShopDB-Admins"
@blur="saveSetting('saml_admin_group', settings.saml_admin_group)"
:disabled="saving"
>
<small class="input-hint">SAML group that grants admin role (leave empty to manage manually)</small>
</label>
</div>
<!-- Locked on until SAML exists. A toggle that reads "Allow Local
Login: off" while password login still works is the dangerous
one on this page: an administrator can believe they have closed
the local door, and nothing tells them otherwise. It is not a
setting anyone can honour yet, so it does not accept a click. -->
<div class="setting-row">
<label class="toggle-label">
<span>Allow Local Login</span>
<button
class="toggle-btn active"
disabled
title="Local login cannot be turned off until SAML sign-in exists"
>
<span class="toggle-slider"></span>
</button>
</label>
<small class="input-hint toggle-hint">
Always on for now. Local accounts are the only way in until SAML
sign-in ships, so this cannot be turned off yet.
</small>
</div>
<div class="setting-row">
<label class="toggle-label">
<span>Auto-Create Users</span>
<button
class="toggle-btn"
:class="{ active: settings.saml_auto_create_users }"
@click="toggleSetting('saml_auto_create_users')"
:disabled="saving"
>
<span class="toggle-slider"></span>
</button>
</label>
<small class="input-hint toggle-hint">Automatically create user accounts on first SAML login</small>
</div>
<div class="status-indicator">
<span class="status-dot" :class="samlStatus"></span>
<span>{{ samlMessage }}</span>
</div>
</template>
</div>
<!-- Collector API credentials. This page is where a site manages who
may authenticate, and the shared collector key is a credential like
any other - it just belongs to scripts rather than people. It is
rendered here because there is no generic key/value settings editor
in this UI: without a control the seeded row can only be flipped by
editing the database by hand, which on an air-gapped prod box means
it is not a setting at all. -->
<div class="setting-group">
<h3>Collector API</h3>
<p class="setting-description">
The four legacy collector routes (<code>/api/collector/pc</code>,
<code>/apps</code>, <code>/heartbeat</code>, <code>/bulk</code>) name
the PC they write in the request body, so the shared collector key is
an any-host write key on them: whoever holds it can rewrite the
logged-in user, last boot time, serial number and installed apps of
any hostname. A collector-scoped managed token is unaffected.
</p>
<div class="setting-row">
<label class="toggle-label">
<span>Refuse the shared key on legacy collector routes</span>
<button
class="toggle-btn"
:class="{ active: legacySharedKeyRefused }"
@click="toggleLegacySharedKey"
:disabled="legacySharedKeySaving"
>
<span class="toggle-slider"></span>
</button>
</label>
<small class="input-hint toggle-hint">
Off by default, which is what every site does today. Turning it on
stops <code>Report-AssetToShopDB.ps1</code> and
<code>Report-PrintersToShopDB.ps1</code> reporting through those
routes until they are moved to per-PC credentials, along with
anything else still calling them with the shared key. Every
shared-key call is logged as "Legacy collector route used with the
shared key" whether this is on or off - read those warnings for a
while first, or turning it on is a guess. On the shipped IIS deploy
that line names the route but not the caller: the X-Forwarded-For
rewrite rule in web.config ships disabled, so every request reaches
the app from 127.0.0.1. Count the routes, not the addresses, unless
that rule is on. The shipped reporters that post to
<code>/api/collector/computers</code> and
<code>/api/collector/printers</code> are not affected. This should
follow the move to per-PC credentials, not lead it.
</small>
</div>
<div v-if="legacySharedKeyError" class="error-message">
{{ legacySharedKeyError }}
</div>
</div>
<div class="setting-group">
<h3>User Management</h3>
<p class="setting-description">
Manage local user accounts and roles. Local accounts work alongside SAML when both are enabled.
</p>
<router-link to="/settings/users" class="view-logs-link">
Manage Users &rarr;
</router-link>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="success" class="settings-success">{{ success }}</div>
</div>
</template>
<script setup>
import { onMounted, computed, ref } from 'vue'
import { useSystemSettings } from '../../composables/systemSettings'
import { settingsApi } from '../../api'
import { apiError } from '../../utils/apiError'
const {
settings, saving, error, success,
loadSettings, saveSetting, toggleSetting,
} = useSystemSettings()
// collector.legacysharedkeyrefused is read and written here directly rather
// than through useSystemSettings: that composable only keeps keys listed in
// its own reactive map, and this one is core-owned but belongs to no page it
// already serves. Default false = today's behaviour, so anything but an
// explicit true renders off.
const LEGACY_SHARED_KEY_SETTING = 'collector.legacysharedkeyrefused'
const legacySharedKeyRefused = ref(false)
const legacySharedKeySaving = ref(false)
const legacySharedKeyError = ref('')
function isTruthySetting(value) {
// A row seeded boolean comes back as a real true; one created by hand
// through the settings API before the key was declared can be the string.
return value === true || value === 'true'
}
async function loadLegacySharedKey() {
try {
const { data } = await settingsApi.get(LEGACY_SHARED_KEY_SETTING)
legacySharedKeyRefused.value = isTruthySetting(data.data?.value)
} catch (e) {
// 404 = never seeded on this install. The backend default is off.
if (e.response?.status !== 404) console.error(e)
legacySharedKeyRefused.value = false
}
}
async function toggleLegacySharedKey() {
const next = !legacySharedKeyRefused.value
if (next && !window.confirm(
'Refuse the shared collector key on the legacy routes?\n\n'
+ 'Report-AssetToShopDB.ps1, Report-PrintersToShopDB.ps1 and anything '
+ 'else still using the shared key on /pc, /apps, /heartbeat or /bulk '
+ 'stop reporting until they move to per-PC credentials.')) {
return
}
legacySharedKeySaving.value = true
legacySharedKeyError.value = ''
try {
// PUT upserts, so this also works where the row was never seeded.
await settingsApi.update(LEGACY_SHARED_KEY_SETTING, next)
legacySharedKeyRefused.value = next
} catch (e) {
legacySharedKeyError.value = apiError(e, 'Failed to save setting')
console.error(e)
} finally {
legacySharedKeySaving.value = false
}
}
// Per-page connection status (stays with the page, not the composable).
const samlStatus = computed(() => {
if (!settings.saml_enabled) return 'inactive'
// 'pending' rather than a healthy state even when every field is filled in:
// nothing is connected until the feature exists.
return 'pending'
})
// Never says "available". It used to report "Configured - SAML login
// available" as soon as two fields held text, which is a claim about a feature
// that does not exist: an administrator could reasonably read that as SSO
// being live and stop issuing local accounts.
const samlMessage = computed(() => {
if (!settings.saml_enabled) return 'Off - local accounts only'
if (!settings.saml_idp_metadata_url) return 'IdP metadata URL not set'
if (!settings.saml_entity_id) return 'Entity ID not set'
return 'Settings saved - waiting on SAML support, local accounts still in use'
})
onMounted(() => {
loadSettings()
loadLegacySharedKey()
})
</script>