diff --git a/CHANGELOG.md b/CHANGELOG.md index f7414d2..9dcac75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,49 @@ ADR-007 and ADR-002. ### Added +- Email sending. A stdlib-only mail service (`shopdb/utils/mailer.py`; + `smtplib`/`ssl`/`email`) reads the existing `email` SMTP settings + settings-first with an `SMTP_*` env fallback, sends multipart HTML+text, and + is a graceful no-op (logs a warning, returns False) when email is disabled or + the host is unset. The SMTP password is never logged. Three flows use it: + (1) New-user welcome + forced first-login password change. Admin-created users + (POST `/api/users`) are flagged `mustchangepassword` (new `users` column, + migration `7d23_user_mustchangepassword`, default false) and sent a + best-effort welcome email with the facility name, username, temporary + password, and sign-in link; the account is created even if mail fails + (response carries a `warning`). Login returns `mustchangepassword`; the + frontend forces the user through a new `/change-password` view (POST + `/api/auth/change-password`, jwt-guarded) before the app, and changing the + password clears the flag and resets lockout counters. A self-service "Change + password" entry is also available from the user menu. + (2) Test email. POST `/api/settings/test-email` (settings.edit) sends a probe + and surfaces any SMTP error with the password scrubbed; wires up the Email / + SMTP settings page "Send Test Email" button. + (3) On-demand alert/report delivery. POST `/api/reports/email` + (reports.export) mails `{subject, columns, rows}` as an HTML table to a + supplied recipient or the site `alert_recipients`; an "Email report" button on + the Warranty and Toner report pages posts the loaded rows. There is no + scheduler: automation is an external cron hitting the endpoint with a scoped + API token (PAT). Documented in `docs/CONFIG.md`. +- Shared asset label/code generator: a single `/print/asset-label//` + page (public, like the other `/print/*` routes) that any asset detail page + opens via a "Print Label" button (machines, computers, printers, network + devices, measuring tools). A no-print controls panel toggles the layout + (`card` badge vs `plain` code-only), the code type (QR vs CODE128 barcode), + and what the code encodes: the asset page link, asset number, serial number, + a per-type custom target template, or - for measuring tools by default - the + tool's inspection location code so every tool at one operation shares one + code (e.g. `0615`). QR codes reuse the shared logo-overlay renderer. New + `printing` settings seed and surface on the Printing & Labels settings page: + `qr_target_machine`, `qr_target_computer`, `qr_target_network_device`, + `qr_target_measuring_tool`; `label_default_style` (default `card`) and + `label_default_codetype` (default `qr`); and a per-asset-type default for what + the code encodes, `label_default_encodes_` (machines default to their + machine number, measuring tools to their inspection location code, the rest to + a page link), all overridable on the label page itself. When the chosen field + has no value (e.g. serial number on an asset with none), the label states so + instead of rendering an empty code. Asset payloads now carry a derived + `locationcode` (leading token of the resolved own/inherited location name). - Support-team contact UX: the settings Support Teams page now manages each team's contacts in a per-team "Contacts (N)" modal (name, SSO, active, plus Add/Edit/Delete) instead of an inline row expander, and the application diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 004f860..1f7fc34 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -170,6 +170,19 @@ them under `instance/branding/`. | `qr_target_printer` | (empty) | Custom URL template for printer QR labels. Blank = link to the printer page on this instance. Placeholders: `{printerid}`, `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{ip}`, `{hostname}`. | | `qr_target_usb` | (empty) | Custom URL template for USB label QR codes. Blank = link to the USB device page. Placeholders: `{id}`, `{serialnumber}`, `{alias}`. | | `usb_label_style` | `barcode` | USB mini-label code style: `barcode` (CODE128 of the serial) or `qr` (QR code linking to the USB QR target). | +| `qr_target_machine` | (empty) | Custom URL template for machine labels. Blank = link to the machine page. Placeholders: `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{name}`, `{pluginid}`. | +| `qr_target_computer` | (empty) | Custom URL template for computer labels. Blank = link to the computer page. Placeholders: `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{name}`, `{pluginid}`. | +| `qr_target_network_device` | (empty) | Custom URL template for network-device labels. Blank = link to the device page. Placeholders: `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{name}`, `{pluginid}`. | +| `qr_target_measuring_tool` | (empty) | Custom URL template for measuring-tool labels. Blank = link to the tool page. Placeholders: `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{name}`, `{pluginid}`, `{locationcode}`, `{locationname}`. | +| `label_default_style` | `card` | Default asset-label layout used when a label first opens: `card` (badge with image and identity) or `plain` (just the code and a caption). | +| `label_default_codetype` | `qr` | Default asset-label code type used when a label first opens: `qr` (QR code) or `barcode` (CODE128). | +| `label_default_encodes_machine` | `assetnumber` | What a machine label encodes by default. | +| `label_default_encodes_computer` | `assetpage` | What a computer label encodes by default. | +| `label_default_encodes_printer` | `assetpage` | What a printer label encodes by default. | +| `label_default_encodes_network_device` | `assetpage` | What a network-device label encodes by default. | +| `label_default_encodes_measuring_tool` | `location` | What a measuring-tool label encodes by default. Values across these five: `assetpage`, `assetnumber`, `serialnumber`, `location` (measuring tools only), or `custom`. Overridable on the label page. | + +The shared asset-label generator lives at `/print/asset-label//` (public, like the other `/print/*` pages; `assettype` is one of `machine`, `computer`, `printer`, `network_device`, `measuring_tool`, and `id` is the asset's plugin id). It can encode the asset page link, the asset number, the serial number, a custom `qr_target_` template, or - for measuring tools by default - the asset's inspection location code (the leading token of the location name, e.g. `0615`). A measuring tool with no location falls back to its asset page. ### map @@ -210,7 +223,48 @@ them under `instance/branding/`. | `smtp_use_tls` | `true` | Use TLS for the SMTP connection. | | `smtp_from_address` | (empty) | From address for outgoing email. | | `smtp_from_name` | `ShopDB` | From name for outgoing email. | -| `alert_recipients` | (empty) | Default alert recipients (comma-separated). | +| `alert_recipients` | (empty) | Default alert/report recipients (comma-separated). | + +#### Email flows and delivery model + +The mail service (`shopdb/utils/mailer.py`, stdlib `smtplib`/`ssl`/`email` +only) reads the keys above settings-first via the cached settings map, with an +environment-variable fallback (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, +`SMTP_PASSWORD`, `SMTP_USE_TLS`, `SMTP_FROM_ADDRESS`, `SMTP_FROM_NAME`, +`SMTP_ALERT_RECIPIENTS`, `SMTP_ENABLED`) applied only when any `SMTP_*` env var +is present. When `smtp_enabled` is false or `smtp_host` is blank, every send is +a graceful no-op that logs a warning and returns without error, so an +unconfigured site never crashes. The SMTP password is never logged. + +Three flows use it: + +- Welcome email. When an admin creates a user (POST `/api/users`), the account + is flagged `mustchangepassword` and a best-effort welcome email is sent with + the facility name (`facility_name`), the username, the temporary password, + and the sign-in link (`site_base_url` + `/login`). Mail is best-effort: the + user is created even if the send fails (the response carries a `warning`). On + first login the API returns `mustchangepassword: true`; the frontend forces + the user through `/change-password` (POST `/api/auth/change-password`) before + the app. Changing the password clears the flag and resets lockout counters. + Set `sendwelcome: false` or `mustchangepassword: false` in the create body to + opt out. + +- Test email. POST `/api/settings/test-email` (settings.edit) sends a probe to + the supplied `to` (or `alert_recipients`). The Email / SMTP settings page + "Send Test Email" button calls it and shows the result; a real SMTP error is + surfaced with the password scrubbed out. + +- Alerts and report delivery (on-demand). POST `/api/reports/email` + (reports.export) takes `{subject, columns, rows, intro?, to?}` and mails the + rows as an HTML table. Recipients default to `alert_recipients` when `to` is + omitted, so the same endpoint serves both report delivery and alerts. Report + pages (Warranty, Toner) carry an "Email report" button that posts the rows + they already loaded. + + There is NO scheduler in this app: sending is on-demand. To automate a + recurring send (e.g. a nightly warranty digest), point an external cron job + at `/api/reports/email` using an API token (PAT) scoped to `reports.export`. + See `docs/IMPORT-API.md` for the token model. ### audit diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 5da01e0..6079a53 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -60,6 +60,9 @@ export const authApi = { return api.post('/auth/refresh', {}, { headers: { Authorization: `Bearer ${refreshToken}` } }) + }, + changePassword(payload) { + return api.post('/auth/change-password', payload) } } @@ -714,6 +717,11 @@ export const reportsApi = { }, pcRelationships(params = {}) { return api.get('/reports/pc-relationships', { params }) + }, + // On-demand report delivery: email the given rows as an HTML table. + // Recipients default to the site Alert Recipients when `to` is omitted. + email(payload) { + return api.post('/reports/email', payload) } } @@ -822,6 +830,9 @@ export const settingsApi = { update(key, value) { return api.put(`/settings/${key}`, { value }) }, + testEmail(to) { + return api.post('/settings/test-email', { to }) + }, create(data) { return api.post('/settings', data) }, diff --git a/frontend/src/components/EmailReportButton.vue b/frontend/src/components/EmailReportButton.vue new file mode 100644 index 0000000..7a9f3e8 --- /dev/null +++ b/frontend/src/components/EmailReportButton.vue @@ -0,0 +1,53 @@ + + + diff --git a/frontend/src/composables/systemSettings.js b/frontend/src/composables/systemSettings.js index 073b072..30c339c 100644 --- a/frontend/src/composables/systemSettings.js +++ b/frontend/src/composables/systemSettings.js @@ -87,6 +87,17 @@ export function useSystemSettings() { qr_target_printer: '', qr_target_usb: '', usb_label_style: 'barcode', + qr_target_machine: '', + qr_target_computer: '', + qr_target_network_device: '', + qr_target_measuring_tool: '', + label_default_style: 'card', + label_default_codetype: 'qr', + label_default_encodes_machine: 'assetnumber', + label_default_encodes_computer: 'assetpage', + label_default_encodes_printer: 'assetpage', + label_default_encodes_network_device: 'assetpage', + label_default_encodes_measuring_tool: 'location', // Email smtp_enabled: false, smtp_host: '', diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index cf21db8..9d1e097 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -45,6 +45,13 @@ const routes = [ component: () => import('../views/Login.vue'), meta: { guest: true } }, + // Forced/self-service password change (standalone, authenticated) + { + path: '/change-password', + name: 'change-password', + component: () => import('../views/ChangePassword.vue'), + meta: { requiresAuth: true } + }, // First-run setup wizard (standalone, admin-only) { path: '/setup', @@ -70,6 +77,14 @@ const routes = [ name: 'print-machine-badge', component: () => import('../views/print/MachineBadge.vue') }, + // Shared asset label/code generator for any asset type (public, like the + // other /print/* routes). assettype = machine|computer|printer| + // network_device|measuring_tool; id = the asset's plugin id. + { + path: '/print/asset-label/:assettype/:id', + name: 'print-asset-label', + component: () => import('../views/print/AssetLabel.vue') + }, { path: '/print/printer-qr', name: 'print-printer-qr-batch', @@ -115,6 +130,13 @@ router.beforeEach(async (to, from, next) => { return next('/') } + // Forced password change: an admin-set temporary password must be replaced + // before the user reaches the rest of the app. Let them log out. + if (authStore.isAuthenticated && authStore.mustChangePassword + && to.path !== '/change-password' && to.path !== '/login') { + return next('/change-password') + } + // Plugin gating: a disabled backend plugin's frontend routes are dead ends. // The enabled list is fetched once and cached; fail-open on error so a blip // cannot brick navigation. Works unauthenticated (endpoint is jwt-optional). diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index a6a0e95..69a54a7 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -13,6 +13,8 @@ export const useAuthStore = defineStore('auth', { roles: (state) => state.user?.roles || [], hasRole: (state) => (role) => state.user?.roles?.includes(role) || false, isAdmin: (state) => state.user?.roles?.includes('admin') || false, + // True when an admin-set temporary password must be changed before use. + mustChangePassword: (state) => !!state.user?.mustchangepassword, // Full name from the employee directory (falls back to username/SSO). displayName: (state) => state.user?.directoryname || state.user?.username || '', // Employee photo URL if the directory has one for this SSO. The directory @@ -57,6 +59,15 @@ export const useAuthStore = defineStore('auth', { localStorage.removeItem('user') }, + // Clear the forced-password-change flag after a successful change so the + // router guard stops steering the user to the change-password view. + clearMustChangePassword() { + if (this.user) { + this.user.mustchangepassword = false + localStorage.setItem('user', JSON.stringify(this.user)) + } + }, + async fetchUser() { try { const response = await authApi.me() diff --git a/frontend/src/views/AppLayout.vue b/frontend/src/views/AppLayout.vue index b6d791e..7aad3de 100644 --- a/frontend/src/views/AppLayout.vue +++ b/frontend/src/views/AppLayout.vue @@ -45,6 +45,7 @@
{{ authStore.displayName }}
{{ authStore.username }}
+ Change password Login diff --git a/frontend/src/views/ChangePassword.vue b/frontend/src/views/ChangePassword.vue new file mode 100644 index 0000000..955722f --- /dev/null +++ b/frontend/src/views/ChangePassword.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/frontend/src/views/machines/MachineDetail.vue b/frontend/src/views/machines/MachineDetail.vue index 0d04090..e2928fa 100644 --- a/frontend/src/views/machines/MachineDetail.vue +++ b/frontend/src/views/machines/MachineDetail.vue @@ -6,6 +6,9 @@ Print Badge + + Print Label + Edit diff --git a/frontend/src/views/measuringtools/MeasuringToolDetail.vue b/frontend/src/views/measuringtools/MeasuringToolDetail.vue index 87573a5..9239bc8 100644 --- a/frontend/src/views/measuringtools/MeasuringToolDetail.vue +++ b/frontend/src/views/measuringtools/MeasuringToolDetail.vue @@ -3,6 +3,9 @@