diff --git a/CHANGELOG.md b/CHANGELOG.md
index af07bb30..aeba1ac2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,213 @@ ADR-007 and ADR-002.
### Added
+- **Reading anything now needs a permission.** A sweep of the URL map found
+ **165 GET routes that answered anybody with no token at all**: the whole
+ asset inventory, every machine, PC, printer and network device, the USB
+ checkout history and who holds which drive, the staff directory, the core
+ reports. Guest access had been argued about one feature-flag at a time while
+ the front door stood open. Every one of them is now behind the read
+ permission it belongs to - `assets.view`, `machines.view`, `printers.view`
+ and so on - which also means the guest role can open exactly the ones a site
+ chooses and nothing else.
+
+ Thirty-three endpoints stay public, each named with its reason in
+ `tests/test_core/test_public_read_surface.py`: the app shell and its
+ bootstrap, the settings allowlist, images fetched by `
` (which
+ carries no Authorization header), what a display kiosk renders before anybody
+ signs in, the parts and USB kiosks, the client scripts that run as SYSTEM
+ before a user session exists, and a secure-send link whose token is itself
+ the authorisation. That file is a standing gate: a new public read fails CI
+ until somebody adds it to the list, in front of a reviewer. Four more reads
+ need a login but no permission because the handler decides from who you are -
+ your own user record, your own API tokens, the role catalogue - and the same
+ test proves each still refuses an anonymous caller.
+
+ The employees plugin gained `employees.view`: the directory is names, SSOs,
+ photographs and reporting lines, and it had no permission at all.
+
+- **A guide's sections can be ticked off while you read it.** Only checklist
+ items were tickable, which answered "what have I done" but not the question a
+ long procedure actually raises - where was I. Somebody looks away to do the
+ thing the guide asked for and looks back at a wall of text. Every step and
+ every heading now carries a checkbox in the reader; ticking one strikes it
+ through and fades it rather than hiding it, because scanning back up the page
+ has to still show what was done. Headings count as well as steps: a guide
+ converted from an HTML article often has fifty headings and no steps at all.
+ Progress is per browser and per version, as it already was for checklists -
+ shopfloor PCs share a login, so one technician's ticks must not greet the
+ next person on that bay.
+
+- **A guest is a role now, not a switch per feature.** Whether somebody with no
+ account may see something was answered one feature at a time: a
+ `securesend_allow_guests` setting, an enable flag per kiosk, and very nearly a
+ third for guides - each with its own settings page and its own hand-written
+ branch inside a route. It is a permissions question, and this app already
+ administers permissions per role, so anyone not signed in now holds the
+ seeded `guest` role. It starts with nothing, so an upgrade opens no door; a
+ site opens one by ticking a box in the same role grid it uses for everybody
+ else, and closes it the same way - taking effect on the next request, not the
+ next restart. `require_permission` consults the role when there is no token,
+ which is the whole mechanism.
+
+ What a guest may be granted is bounded: any `.view` or `.read` permission,
+ plus writes a plugin has explicitly declared safe to perform without an
+ account (`get_guest_grantable_permissions`, contract 0.21.0). Secure Send
+ declares its create permission, because sending a password to a vendor is the
+ one write the product means to offer somebody who never signs in. Everything
+ else is refused by the API and greyed out in the grid rather than warned
+ about. A guest write records no creator - the audit trail can say when and
+ from where, never who - and that is the reason for the bar.
+
+ GE-Enforce and Slides gained read permissions (`geenforce.view`,
+ `slides.view`) so they can be opened to guests without handing out the manage
+ right; their read routes accept either, so nothing an existing manager could
+ reach has moved. The navigation endpoint now returns only what the caller can
+ actually reach, so a signed-out visitor sees the pages that were opened to
+ them rather than a sidebar of links that bounce off a login page.
+
+- **USB check-in / check-out audit report.** CMMC removable-media tracking is an
+ audit obligation and there was no report for it - answering "who had this
+ drive, and when" meant paging through the check-in/out list by hand. The
+ window is named rather than a day count: today, last month, six months, a
+ year, or all time. Events rather than totals, because that is the question an
+ audit actually asks, with a CSV export for the people who want it in a
+ spreadsheet. Works against the external `cmmc_usb` log or the self-hosted
+ tables; in self-hosted mode one stored loan expands into a take and a return,
+ so a returned drive is distinguishable from one still out.
+
+- **`flask guides export` / `flask guides import` - a guide can reach another
+ site.** Sites are separate databases and the ones that matter are air-gapped
+ from wherever a guide gets written, so a procedure written once was stranded
+ where it was typed. Export writes one `.guide.json` per guide with its images
+ carried inside it - one file, because a bundle that needs its images copied
+ alongside it is a bundle that will eventually arrive without them - and
+ import reads them back. The slug travels, since that is the address somebody
+ pastes into a work order and the same guide should answer to the same name on
+ every site; ids, revision history and publication state do not, because those
+ belong to the database that issued them. Imports arrive as drafts;
+ `--replace` updates a guide already carrying that slug rather than creating a
+ second one, which is how a corrected guide is re-shipped.
+
+- **`flask guides import-html` - an existing HTML article becomes a guide.**
+ A site adopting this app arrives holding procedures already written: styled
+ HTML pages that get mailed around and opened off a share. Retyping one into
+ the editor is how a good procedure stays on a share forever. The importer
+ reads the page and produces blocks - steps, callouts, code, checklists,
+ tables, lists - and lifts embedded images into `guideimages` so a 1.6 MB
+ document stops carrying its screenshots as base64. Imports land as DRAFTS,
+ and `--dry-run` (the default) reports what it would produce without writing.
+ Conversion is deliberately lossy: colour, fonts and layout go, because that
+ is the whole reason a guide is stored as blocks rather than markup. The
+ blocks go through the same validator the editor uses, so an import cannot
+ store something the editor could not have produced.
+
+### Fixed
+
+- **Tech Tools was invisible to anyone without an account**, even though every
+ tool on the page is deliberately usable without one and Secure Send is the
+ single write this product offers a guest. The nav item named no permission,
+ because the plugin had none to name, so the sidebar filter dropped it. The
+ plugin gained `tools.view`, and the item now names either that or
+ `tools.secrets` - somebody granted only Secure Send still needs the entry
+ that leads to it.
+
+- **The sidebar offered pages the reader could not open, and hid ones they
+ could.** A nav item is hidden from anybody who does not hold its permission,
+ but only four items ever declared one - so the filter did nothing for the
+ rest, and a signed-out visitor granted `machines.view` still saw an empty
+ sidebar. Every item now names the read permission its page needs, in the
+ plugins, in the core navigation endpoint and in the frontend's fallback list.
+ A signed-in member with a sparse role will notice the same correction: a link
+ that could only answer 403 no longer appears.
+
+- **Every report offers to email itself, not just two of them.** `POST
+ /api/reports/email` has always taken any `{columns, rows}`, but the button was
+ on the toner and warranty pages only - because those two happen to be their
+ own routed pages, and the reports page had no button at all. It is now beside
+ Export CSV for every report the page can build a table from, which is all of
+ them: the plugin reports through their declared columns, the core reports
+ through the flat list they return, and the asset inventory summary by
+ flattening its three tables into one with the grouping named in a column.
+ Presentation-only fields - a status colour, a link id - are left out, since a
+ column of hex codes is worse than no column. The button is hidden without the
+ `reports.export` permission the endpoint enforces, on all three pages: offering
+ somebody a button that can only answer 403 is worse than not offering it.
+
+- **A long drive name pushed Take and Return out of the card.** On the USB kiosk
+ the name is a flex item, which defaults to `min-width: auto`, so it kept its
+ full intrinsic width and shoved the button through the side of the card -
+ leaving a drive nobody could take. Names now wrap, including the serial-style
+ ones that carry no spaces at all, and a description is clamped to two lines so
+ one long name cannot make every card in its row tall. The locker line is never
+ clipped: it is how somebody finds the thing.
+
+- **Muted text on the USB kiosk and in guides was not muted.** Both named a
+ `--textmuted` variable this app has never defined, so the browser dropped the
+ declaration and the hints, subtitles and locker lines rendered at full
+ strength against the text they were meant to sit behind. The variable is
+ `--text-light`.
+
+- **Plugin reports rendered a raw JSON dump.** The reports page ran a hardcoded
+ switch over five core report ids and fell through to
+ `console.error('Unknown report')`, and its results panel was a matching chain
+ of per-id templates ending in a `JSON.stringify`. So a plugin could register a
+ report, watch it appear in the catalogue, and get a wall of JSON when it was
+ opened - which is where the three printedparts reports have been since they
+ were added. Any report that declares an endpoint is now fetched from it, and
+ any report returning `{columns, rows}` renders as a table. A plugin needs no
+ core code to have a working report. CSV export follows the same endpoint
+ rather than assuming `/api/reports/`, which does not exist for a plugin
+ report, and a report that fails now says so instead of showing an empty panel.
+ Export CSV is fetched through the api client rather than `window.open`: a new
+ window carries no Authorization header, so every report behind a real
+ `jwt_required` answered the export button with a 401. The core reports are
+ jwt-optional, which is the only reason the old way ever appeared to work.
+
+- **Reports had no way to choose a time period.** Endpoints accepted a window;
+ the page offered no control for it, so `printedparts-consumption` was
+ permanently stuck on its 30-day default.
+
+- **USB kiosk: check a drive out and back in without a login.** The people who
+ actually take USB drives are standing at the cabinet, and most of them have no
+ ShopDB account - so check-out and check-in were gated behind a login that was
+ not doing much anyway, since both modes already attribute the action to a
+ scanned BADGE rather than to the signed-in user. `/usb-kiosk` is a full-screen
+ touch screen modelled on the parts kiosk, and works against the self-hosted
+ tables or an existing `cmmc_usb` database.
+
+ It meets the same bar as the parts kiosk: it can move a device between
+ checked-out and checked-in and nothing else - no create, edit or retire - and
+ it does not exist at all until a site sets `usb_kiosk_enabled`. An
+ unauthenticated write is a decision about the door.
+
+ The drives carry no barcode, so unlike the parts kiosk there is nothing to
+ present and the screen has to offer a LIST. That widens what a logged-out
+ screen exposes, so the list is not anonymous: an SSO comes first and has to
+ resolve to a real person before any drive is named - the same bar taking one
+ already had to clear. The main screen shows the caller's own drives, what is
+ free, and a COUNT of everything else; the holders of other people's drives
+ appear only behind a deliberate second tap, so a passer-by cannot read the
+ whole cabinet off a wall panel.
+
+ A badge that resolves to no employee is REFUSED, unlike the signed-in route
+ which creates the user row: a checkout nobody can trace to a person defeats
+ the point, and at a kiosk there is no operator standing there to vouch for it.
+
+ Returning somebody else's drive asks first, then records the badge that was
+ scanned - so the log says who actually handed it back, which is the honest
+ record and needs no change to a schema this app does not own. Refusing a
+ mismatch outright is available as a setting, but is not the default: a drive
+ left behind by somebody on leave would then be unreturnable at the kiosk,
+ which just moves the problem to a person with a login.
+
+ The list of free drives is filtered from a search box, and lays itself out in
+ as many columns as the panel is wide - up to four on a wall display. A cabinet
+ of thirty drives was one long column somebody had to read through. A drive is
+ found by its id, by what is written on it, or by where it lives, in whatever
+ word order comes naturally: "locker engineer" finds the engineering locker,
+ because that is what people say.
+
- **Audit log retention.** The audit log is append-only and nothing ever removed
a row, so it grew for as long as a site ran - here to the point where the
audit page was tens of thousands of pages deep and therefore useless, because
diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js
index 37562394..93dc2df6 100644
--- a/frontend/src/router/index.js
+++ b/frontend/src/router/index.js
@@ -131,6 +131,7 @@ const TITLE_SPELLINGS = {
'knowledgebase': 'Knowledge Base',
'printedparts': '3D Printed Parts',
'parts-kiosk': 'Parts Kiosk',
+ 'usb-kiosk': 'USB Kiosk',
'tv': 'TV Slideshow',
'shopfloor': 'Shopfloor Dashboard',
'measuringtools': 'Measuring Tools',
diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js
index 2f0a96dd..9d8faaf5 100644
--- a/frontend/src/stores/auth.js
+++ b/frontend/src/stores/auth.js
@@ -75,13 +75,25 @@ export const useAuthStore = defineStore('auth', {
}
},
+ // Re-read the signed-in user from the server, permissions included.
+ //
+ // The login response is the ONLY thing that used to populate this, and it
+ // is cached in localStorage - so granting somebody a permission did nothing
+ // visible until they happened to log out and back in. They were told they
+ // had rights, the button stayed hidden, and nothing explained why. Called
+ // on app boot so a permission change lands on the next page load.
+ //
+ // Only a 401 logs out. A transient failure - API restarting, network blip -
+ // must not throw somebody out of a session they still hold; the cached user
+ // is stale, not invalid.
async fetchUser() {
try {
const response = await authApi.me()
this.user = response.data.data
await this.enrichFromDirectory()
+ localStorage.setItem('user', JSON.stringify(this.user))
} catch (error) {
- this.logout()
+ if (error.response?.status === 401) this.logout()
}
},
diff --git a/frontend/src/utils/reportTable.js b/frontend/src/utils/reportTable.js
new file mode 100644
index 00000000..41e207d3
--- /dev/null
+++ b/frontend/src/utils/reportTable.js
@@ -0,0 +1,69 @@
+// Turning a report payload into the one table shape everything else speaks.
+//
+// POST /api/reports/email has always taken {columns, rows} from any caller, but
+// only two pages ever offered an Email report button - the two that happen to
+// be their own routed pages. Every other report was reachable only from the
+// reports page, which had no button at all. This builds that table from
+// whatever shape the open report returned, so the offer is the same everywhere.
+
+export function titleCase (text) {
+ return String(text || '')
+ .split(/[\s_-]+/)
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(' ')
+}
+
+// Presentation-only keys. A colour swatch and a row id mean nothing in an
+// email, and a column of hex codes is worse than no column at all.
+const EMAIL_SKIP = ['color', 'linkid', 'linkurl', 'id']
+
+function emailcolumns (keys) {
+ return keys.filter(key => !EMAIL_SKIP.includes(key))
+ .map(key => ({ key, label: titleCase(key) }))
+}
+
+export function emailTableFrom (data) {
+ if (!data) return null
+
+ // Any report that speaks the {columns, rows} contract - every plugin report,
+ // and any core one that adopts it. Declared columns win; otherwise take them
+ // from the first row, the same way the on-screen table does.
+ if (Array.isArray(data.rows) && data.rows.length) {
+ const keys = (Array.isArray(data.columns) && data.columns.length)
+ ? data.columns
+ : Object.keys(data.rows[0])
+ return { columns: emailcolumns(keys), rows: data.rows }
+ }
+
+ // The core reports, which return a flat list under `data`.
+ if (Array.isArray(data.data) && data.data.length) {
+ return { columns: emailcolumns(Object.keys(data.data[0])), rows: data.data }
+ }
+
+ // Asset inventory is three tables. One email, so they become one table with
+ // the grouping named in a column - sending a third of it would be worse.
+ if (data.data && typeof data.data === 'object') {
+ const rows = []
+ for (const [section, entries] of Object.entries(data.data)) {
+ if (!Array.isArray(entries)) continue
+ for (const entry of entries) {
+ rows.push({
+ section: titleCase(section),
+ name: entry.type || entry.status || entry.location || '',
+ count: entry.count,
+ })
+ }
+ }
+ if (!rows.length) return null
+ return {
+ columns: [{ key: 'section', label: 'Grouping' },
+ { key: 'name', label: 'Name' },
+ { key: 'count', label: 'Count' }],
+ rows,
+ }
+ }
+
+ // An empty report has nothing to send. The button hides rather than mailing
+ // somebody a table with no rows in it.
+ return null
+}
diff --git a/frontend/src/utils/reportTable.spec.js b/frontend/src/utils/reportTable.spec.js
new file mode 100644
index 00000000..a63167f2
--- /dev/null
+++ b/frontend/src/utils/reportTable.spec.js
@@ -0,0 +1,84 @@
+// Every shape a report on this app actually returns, because the Email report
+// button now has to build a table out of all of them rather than only the two
+// that had their own page.
+
+import { describe, it, expect } from 'vitest'
+
+import { emailTableFrom, titleCase } from './reportTable'
+
+describe('emailTableFrom', () => {
+ it('has nothing to send for a report that has not run', () => {
+ expect(emailTableFrom(null)).toBe(null)
+ expect(emailTableFrom({})).toBe(null)
+ })
+
+ it('uses the declared columns of a {columns, rows} report', () => {
+ const table = emailTableFrom({
+ columns: ['timestamp', 'action', 'device_id'],
+ rows: [{ timestamp: 't', action: 'check-out', device_id: 'USB-3' }],
+ })
+ expect(table.columns).toEqual([
+ { key: 'timestamp', label: 'Timestamp' },
+ { key: 'action', label: 'Action' },
+ { key: 'device_id', label: 'Device Id' },
+ ])
+ expect(table.rows).toHaveLength(1)
+ })
+
+ it('falls back to the first row when a report declares no columns', () => {
+ const table = emailTableFrom({ rows: [{ item: 'a', takes: 2 }] })
+ expect(table.columns.map(c => c.key)).toEqual(['item', 'takes'])
+ })
+
+ it('builds a table from a core report that returns a flat list', () => {
+ const table = emailTableFrom({
+ report: 'machines_by_type',
+ total: 231,
+ data: [{ machinetype: 'Lathe', description: 'd', count: 12 }],
+ })
+ expect(table.columns.map(c => c.key)).toEqual(['machinetype', 'description', 'count'])
+ expect(table.rows[0].count).toBe(12)
+ })
+
+ it('drops the keys that only mean something on screen', () => {
+ const table = emailTableFrom({
+ data: [{ status: 'In Use', color: '#28a745', count: 572 }],
+ })
+ expect(table.columns.map(c => c.key)).toEqual(['status', 'count'])
+
+ const kb = emailTableFrom({
+ data: [{ linkid: 4, linkurl: 'http://x', shortdescription: 'How to', clicks: 9 }],
+ })
+ expect(kb.columns.map(c => c.key)).toEqual(['shortdescription', 'clicks'])
+ })
+
+ it('flattens the three tables of asset inventory into one, named', () => {
+ const table = emailTableFrom({
+ data: {
+ bytype: [{ type: 'computer', count: 289 }],
+ bystatus: [{ status: 'In Use', color: '#0f0', count: 572 }],
+ bylocation: [{ location: 'Cell A', count: 3 }],
+ },
+ })
+ expect(table.columns.map(c => c.key)).toEqual(['section', 'name', 'count'])
+ expect(table.rows).toEqual([
+ { section: 'Bytype', name: 'computer', count: 289 },
+ { section: 'Bystatus', name: 'In Use', count: 572 },
+ { section: 'Bylocation', name: 'Cell A', count: 3 },
+ ])
+ })
+
+ it('has nothing to send for a report that came back empty', () => {
+ expect(emailTableFrom({ data: [], total: 0 })).toBe(null)
+ expect(emailTableFrom({ columns: ['a'], rows: [] })).toBe(null)
+ expect(emailTableFrom({ data: { bytype: [], bystatus: [] } })).toBe(null)
+ })
+})
+
+describe('titleCase', () => {
+ it('turns a column key into a heading', () => {
+ expect(titleCase('locker_location')).toBe('Locker Location')
+ expect(titleCase('machines-by-type')).toBe('Machines By Type')
+ expect(titleCase('')).toBe('')
+ })
+})
diff --git a/frontend/src/views/AppLayout.vue b/frontend/src/views/AppLayout.vue
index d110bf8f..84452a9c 100644
--- a/frontend/src/views/AppLayout.vue
+++ b/frontend/src/views/AppLayout.vue
@@ -11,7 +11,9 @@
-