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 @@ -