From 17df88ced5e70635b11fc1dce851c1b3d4144e46 Mon Sep 17 00:00:00 2001 From: seb Date: Mon, 2 Nov 2026 00:19:19 +0100 Subject: [PATCH] u --- docs/api/README.md | 9 + docs/api/routes-all.md | 33 +- docs/api/routes.md | 33 +- docs/api/routes/auth/selected-company.md | 1 + docs/api/routes/autoinvite/create.md | 55 ++++ docs/api/routes/autoinvite/delete.md | 36 +++ docs/api/routes/autoinvite/list.md | 36 +++ docs/api/routes/company/create.md | 3 +- docs/api/routes/company/list.md | 3 +- docs/api/routes/company/update.md | 3 +- docs/api/routes/companyuser/create.md | 57 ++++ docs/api/routes/companyuser/delete.md | 40 +++ docs/api/routes/companyuser/list.md | 36 +++ docs/api/routes/companyuser/update.md | 57 ++++ docs/api/routes/events.md | 2 + src/api/openapi.js | 30 ++ src/api/routes/auth/selectedCompany.js | 9 + src/api/routes/autoinvite.js | 271 ++++++++++++++++ src/api/routes/companyuser.js | 385 +++++++++++++++++++++++ src/api/routes/events.js | 6 + src/db/autoinvite.js | 16 + src/db/companyuser.js | 21 ++ src/db/store.js | 1 + 23 files changed, 1135 insertions(+), 8 deletions(-) create mode 100644 docs/api/routes/autoinvite/create.md create mode 100644 docs/api/routes/autoinvite/delete.md create mode 100644 docs/api/routes/autoinvite/list.md create mode 100644 docs/api/routes/companyuser/create.md create mode 100644 docs/api/routes/companyuser/delete.md create mode 100644 docs/api/routes/companyuser/list.md create mode 100644 docs/api/routes/companyuser/update.md create mode 100644 src/api/routes/autoinvite.js create mode 100644 src/api/routes/companyuser.js create mode 100644 src/db/autoinvite.js create mode 100644 src/db/companyuser.js diff --git a/docs/api/README.md b/docs/api/README.md index 7c14491..c5316c4 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -98,6 +98,9 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/auth/logout.md`](routes/auth/logout.md) | `POST /auth/logout` | | [`routes/auth/me.md`](routes/auth/me.md) | `GET /auth/me` | | [`routes/auth/selected-company.md`](routes/auth/selected-company.md) | `PUT /auth/selected-company` | +| [`routes/autoinvite/list.md`](routes/autoinvite/list.md) | `GET /autoinvite` | +| [`routes/autoinvite/create.md`](routes/autoinvite/create.md) | `POST /autoinvite` | +| [`routes/autoinvite/delete.md`](routes/autoinvite/delete.md) | `DELETE /autoinvite/{id}` | | [`routes/backup.md`](routes/backup.md) | `GET /backup` | | [`routes/backup/_id/vfs.md`](routes/backup/_id/vfs.md) | `GET /backup/{id}/vfs` | | [`routes/backup/_id/vfs/recover.md`](routes/backup/_id/vfs/recover.md) | `POST /backup/{id}/vfs/recover` | @@ -140,6 +143,10 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/companysetting/set.md`](routes/companysetting/set.md) | `PUT /company/setting/{key}` | | [`routes/company/delete.md`](routes/company/delete.md) | `DELETE /company/{id}` | | [`routes/company/update.md`](routes/company/update.md) | `PUT /company/{id}` | +| [`routes/companyuser/list.md`](routes/companyuser/list.md) | `GET /companyuser` | +| [`routes/companyuser/create.md`](routes/companyuser/create.md) | `POST /companyuser` | +| [`routes/companyuser/delete.md`](routes/companyuser/delete.md) | `DELETE /companyuser/{id}` | +| [`routes/companyuser/update.md`](routes/companyuser/update.md) | `PATCH /companyuser/{id}` | | [`routes/customergroup/list.md`](routes/customergroup/list.md) | `GET /customergroup` | | [`routes/customergroup/create.md`](routes/customergroup/create.md) | `POST /customergroup` | | [`routes/customergroup/order.md`](routes/customergroup/order.md) | `PUT /customergroup/order` | @@ -352,10 +359,12 @@ Clients subscribe via [`GET /events`](routes/events.md) (`text/event-stream`). A | `article-` | `PUT /article/{sku}/brand`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/eans`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}`, `PUT /article/{sku}/images/order`, `PUT /article/{sku}/prices`, `POST /article/{sku}/supplier`, `PUT /article/{sku}/supplier/order`, `DELETE /article/{sku}/supplier/{id}`, `PATCH /article/{sku}/supplier/{id}`, `PUT /article/{sku}/track_stock`, `PUT /article/{sku}/weight`, `POST /stock/adjust` | | `articles` | `POST /article`, `DELETE /article/{sku}`, `PATCH /article/{sku}`, `DELETE /category/{id}`, `DELETE /shipment/{id}`, `POST /stock/accept`, `POST /stock/adjust`, `POST /stock/ship` | | `attributename` | `POST /attributename`, `PUT /attributename/order`, `DELETE /attributename/{id}`, `PATCH /attributename/{id}`, `DELETE /attributevalue/{id}/image`, `POST /attributevalue/{id}/image` | +| `autoinvite` | `POST /autoinvite`, `DELETE /autoinvite/{id}` | | `brand` | `POST /brand`, `DELETE /brand/{id}`, `PATCH /brand/{id}`, `DELETE /brand/{id}/image`, `POST /brand/{id}/image` | | `carrier` | `POST /carrier`, `PUT /carrier/order`, `DELETE /carrier/{id}`, `PATCH /carrier/{id}` | | `category` | `POST /category`, `PUT /category/order`, `DELETE /category/{id}`, `PATCH /category/{id}`, `DELETE /category/{id}/image`, `POST /category/{id}/image`, `POST /category/{id}/image/generate` | | `company` | `PUT /auth/selected-company`, `POST /company`, `DELETE /company/{id}`, `PUT /company/{id}` | +| `companyuser` | `POST /companyuser`, `DELETE /companyuser/{id}`, `PATCH /companyuser/{id}` | | `delivery` | `DELETE /delivery/{id}/file/{fileId}`, `POST /delivery/{id}/pdf`, `POST /stock/accept` | | `labeltemplate` | `POST /labeltemplate`, `DELETE /labeltemplate/{id}`, `PATCH /labeltemplate/{id}`, `PUT /labeltemplate/{id}` | | `ledger` | `DELETE /shipment/{id}`, `POST /stock/accept`, `POST /stock/adjust`, `POST /stock/ship` | diff --git a/docs/api/routes-all.md b/docs/api/routes-all.md index 785c236..1d6e31f 100644 --- a/docs/api/routes-all.md +++ b/docs/api/routes-all.md @@ -1,6 +1,6 @@ # Dash API Routes Overview (All) -Overview of all 307 API endpoints across all modules. +Overview of all 314 API endpoints across all modules. ### Notation @@ -27,7 +27,7 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body). - `T16` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }` - `T17` = `{ id: int, name: str, sort: int, exclusive: bool, values: T30[], articleCount?: int }` - `T18` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str, started_by?: int|null, first_message?: str|null }` -- `T19` = `{ id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num }` +- `T19` = `{ id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num, role: str }` - `T20` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null, shippable: bool }` - `T21` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null, shippable?: bool }` - `T22` = `{ id: int, agentprinterId: int, type: str, state: str, data: str, createdAt: str, printeragentId?: int|null, completedAt?: str|null }` @@ -276,6 +276,18 @@ _none_ - Params: `body: { companyId: int }` - Returns: `200 { user: T35 }` +### Section: /autoinvite + +#### List auto-invitations — `GET /autoinvite` +_none_ +- Returns: `200 { autoinvites: { id: int, email: str, role: str, createdAt: str }[] }` +#### Create auto-invitation — `POST /autoinvite` +- Params: `body: { email: str, role?: str }` +- Returns: `201 { autoinvite: { id: int, email: str, role: str } }` +#### Delete auto-invitation — `DELETE /autoinvite/{id}` +- Params: `id: int` +- Returns: `204 void` + ### Section: /backup #### List backups and service status — `GET /backup` @@ -417,6 +429,21 @@ _none_ - Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }` - Returns: `200 { company: T19 }` +### Section: /companyuser + +#### List company users — `GET /companyuser` +_none_ +- Returns: `200 { companyusers: { id: int, googleAuthId: int, email: str, role: str }[] }` +#### Add an existing user to the company — `POST /companyuser` +- Params: `body: { email: str, role?: str }` +- Returns: `201 { companyuser: { id: int, googleAuthId: int, email: str, role: str } }` +#### Remove a user from the company — `DELETE /companyuser/{id}` +- Params: `id: int` +- Returns: `204 void` +#### Change a company user's role — `PATCH /companyuser/{id}` +- Params: `id: int, body: { role: str }` +- Returns: `200 { companyuser: { id: int, googleAuthId: int, role: str } }` + ### Section: /customergroup #### List customer groups — `GET /customergroup` @@ -523,6 +550,8 @@ _none_ - `ledger`: `{}` - `attributename`: `{}` - `propertyname`: `{}` + - `companyuser`: `{}` + - `autoinvite`: `{}` - `labeltemplate`: `{}` - `printer`: `{}` - `printeragent`: `{}` diff --git a/docs/api/routes.md b/docs/api/routes.md index 4f8029d..9caf05e 100644 --- a/docs/api/routes.md +++ b/docs/api/routes.md @@ -1,6 +1,6 @@ # Dash API Routes Overview -Overview of all 189 API endpoints across all modules. +Overview of all 196 API endpoints across all modules. ### Notation @@ -183,6 +183,18 @@ _none_ - Params: `id: int` - Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T22[] } }` +### Section: /autoinvite + +#### List auto-invitations — `GET /autoinvite` +_none_ +- Returns: `200 { autoinvites: { id: int, email: str, role: str, createdAt: str }[] }` +#### Create auto-invitation — `POST /autoinvite` +- Params: `body: { email: str, role?: str }` +- Returns: `201 { autoinvite: { id: int, email: str, role: str } }` +#### Delete auto-invitation — `DELETE /autoinvite/{id}` +- Params: `id: int` +- Returns: `204 void` + ### Section: /brand #### List brands — `GET /brand` @@ -268,7 +280,7 @@ _none_ #### List companies — `GET /company` _none_ -- Returns: `200 { companies: { id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num }[] }` +- Returns: `200 { companies: { id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num, role: str }[] }` #### Get company history — `GET /company/history` - Params: `query: limit?: int` - Returns: `200 { history: { id: int, date: str, text: str, companyuserId?: int|null }[], truncated: bool }` @@ -289,7 +301,22 @@ _none_ - Returns: `200 { setting: { key: str, value: str|null } }` #### Update company — `PUT /company/{id}` - Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }` -- Returns: `200 { company: { id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num } }` +- Returns: `200 { company: { id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num, role: str } }` + +### Section: /companyuser + +#### List company users — `GET /companyuser` +_none_ +- Returns: `200 { companyusers: { id: int, googleAuthId: int, email: str, role: str }[] }` +#### Add an existing user to the company — `POST /companyuser` +- Params: `body: { email: str, role?: str }` +- Returns: `201 { companyuser: { id: int, googleAuthId: int, email: str, role: str } }` +#### Remove a user from the company — `DELETE /companyuser/{id}` +- Params: `id: int` +- Returns: `204 void` +#### Change a company user's role — `PATCH /companyuser/{id}` +- Params: `id: int, body: { role: str }` +- Returns: `200 { companyuser: { id: int, googleAuthId: int, role: str } }` ### Section: /customergroup diff --git a/docs/api/routes/auth/selected-company.md b/docs/api/routes/auth/selected-company.md index 784e299..a50efd0 100644 --- a/docs/api/routes/auth/selected-company.md +++ b/docs/api/routes/auth/selected-company.md @@ -43,6 +43,7 @@ Updated user |--------|-------------| | 400 | Invalid companyId — `{"error":"…"}` | | 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized (the user is not a member of this company) — `{"error":"…"}` | | 500 | Server error — `{"error":"…"}` | --- diff --git a/docs/api/routes/autoinvite/create.md b/docs/api/routes/autoinvite/create.md new file mode 100644 index 0000000..6ce3890 --- /dev/null +++ b/docs/api/routes/autoinvite/create.md @@ -0,0 +1,55 @@ +# `POST /autoinvite` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Create auto-invitation + +Registers an email for automatic membership. When a user with this email registers, +the invitation is removed from the list and the user is added to the company with the given role. +Emails that are already registered are rejected (409); use `POST /companyuser` for those. Admin only. + + +## Body (JSON) + +| Field | Required | Type | Description | +|-------|----------|------|-------------| +| `email` | yes | string | | +| `role` | no | string | | + +```json +{ + "email": "newhire@example.com", + "role": "user" +} +``` + +## Signals + +On success, emits SSE signal: `autoinvite` (see [`GET /events`](../events.md)). + +## Response `201` + +Created invitation + +```json +{ + "autoinvite": { + "id": 1, + "email": "…", + "role": "admin" + } +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | No company selected, invalid email or invalid role — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized (admin of the selected company required) — `{"error":"…"}` | +| 409 | User already registered or invitation already exists — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/autoinvite/delete.md b/docs/api/routes/autoinvite/delete.md new file mode 100644 index 0000000..9f0356c --- /dev/null +++ b/docs/api/routes/autoinvite/delete.md @@ -0,0 +1,36 @@ +# `DELETE /autoinvite/{id}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Delete auto-invitation + +Deletes a pending auto-invitation of the selected company. Admin only. + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | | + +## Signals + +On success, emits SSE signal: `autoinvite` (see [`GET /events`](../events.md)). + +## Response `204` + +Deleted + +_No content_ + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Invalid id or no company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized (admin of the selected company required) — `{"error":"…"}` | +| 404 | Invitation not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/autoinvite/list.md b/docs/api/routes/autoinvite/list.md new file mode 100644 index 0000000..80a93dd --- /dev/null +++ b/docs/api/routes/autoinvite/list.md @@ -0,0 +1,36 @@ +# `GET /autoinvite` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +List auto-invitations + +Lists pending auto-invitations of the selected company. Admin only. + +## Response `200` + +Pending invitations + +```json +{ + "autoinvites": [ + { + "id": 1, + "email": "…", + "role": "admin", + "createdAt": "…" + } + ] +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | No company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized (admin of the selected company required) — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/company/create.md b/docs/api/routes/company/create.md index 305b126..5891499 100644 --- a/docs/api/routes/company/create.md +++ b/docs/api/routes/company/create.md @@ -42,7 +42,8 @@ Created; selected company updated "iso639": "…", "taxrate": 1, "reducedtaxrate": 1, - "taxfreerate": 1 + "taxfreerate": 1, + "role": "admin" }, "user": { "id": 1, diff --git a/docs/api/routes/company/list.md b/docs/api/routes/company/list.md index 47003b8..87cbca7 100644 --- a/docs/api/routes/company/list.md +++ b/docs/api/routes/company/list.md @@ -21,7 +21,8 @@ Company list "iso639": "…", "taxrate": 1, "reducedtaxrate": 1, - "taxfreerate": 1 + "taxfreerate": 1, + "role": "admin" } ] } diff --git a/docs/api/routes/company/update.md b/docs/api/routes/company/update.md index d664a55..98554c4 100644 --- a/docs/api/routes/company/update.md +++ b/docs/api/routes/company/update.md @@ -48,7 +48,8 @@ Updated "iso639": "…", "taxrate": 1, "reducedtaxrate": 1, - "taxfreerate": 1 + "taxfreerate": 1, + "role": "admin" } } ``` diff --git a/docs/api/routes/companyuser/create.md b/docs/api/routes/companyuser/create.md new file mode 100644 index 0000000..9da1113 --- /dev/null +++ b/docs/api/routes/companyuser/create.md @@ -0,0 +1,57 @@ +# `POST /companyuser` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Add an existing user to the company + +Adds an existing (registered) user, identified by email, to the selected company. Admin only. +The added user can then switch to this company via `PUT /auth/selected-company`. +For emails that are not registered yet use `POST /autoinvite`. + + +## Body (JSON) + +| Field | Required | Type | Description | +|-------|----------|------|-------------| +| `email` | yes | string | | +| `role` | no | string | | + +```json +{ + "email": "colleague@example.com", + "role": "user" +} +``` + +## Signals + +On success, emits SSE signal: `companyuser` (see [`GET /events`](../events.md)). + +## Response `201` + +Created company user + +```json +{ + "companyuser": { + "id": 1, + "googleAuthId": 1, + "email": "…", + "role": "admin" + } +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | No company selected, invalid email or invalid role — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized (admin of the selected company required) — `{"error":"…"}` | +| 404 | No registered user with this email — `{"error":"…"}` | +| 409 | User already in company — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/companyuser/delete.md b/docs/api/routes/companyuser/delete.md new file mode 100644 index 0000000..93f181c --- /dev/null +++ b/docs/api/routes/companyuser/delete.md @@ -0,0 +1,40 @@ +# `DELETE /companyuser/{id}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Remove a user from the company + +Removes a member from the selected company. History entries stay but lose their author link; +if the removed user had this company selected, their selection is cleared. +The last admin cannot be removed. Admin only. + + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | | + +## Signals + +On success, emits SSE signal: `companyuser` (see [`GET /events`](../events.md)). + +## Response `204` + +Removed + +_No content_ + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Invalid id or no company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized (admin of the selected company required) — `{"error":"…"}` | +| 404 | Company user not found — `{"error":"…"}` | +| 409 | Cannot remove the last admin — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/companyuser/list.md b/docs/api/routes/companyuser/list.md new file mode 100644 index 0000000..769590a --- /dev/null +++ b/docs/api/routes/companyuser/list.md @@ -0,0 +1,36 @@ +# `GET /companyuser` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +List company users + +Lists the members of the selected company with their role. Admin only. + +## Response `200` + +Company members + +```json +{ + "companyusers": [ + { + "id": 1, + "googleAuthId": 1, + "email": "…", + "role": "admin" + } + ] +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | No company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized (admin of the selected company required) — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/companyuser/update.md b/docs/api/routes/companyuser/update.md new file mode 100644 index 0000000..055c41b --- /dev/null +++ b/docs/api/routes/companyuser/update.md @@ -0,0 +1,57 @@ +# `PATCH /companyuser/{id}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Change a company user's role + +Changes the role of a member of the selected company. The last admin cannot be demoted. Admin only. + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | | + +## Body (JSON) + +| Field | Required | Type | Description | +|-------|----------|------|-------------| +| `role` | yes | string | | + +```json +{ + "role": "admin" +} +``` + +## Signals + +On success, emits SSE signal: `companyuser` (see [`GET /events`](../events.md)). + +## Response `200` + +Updated company user + +```json +{ + "companyuser": { + "id": 1, + "googleAuthId": 1, + "role": "admin" + } +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Invalid id, role or no company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized (admin of the selected company required) — `{"error":"…"}` | +| 404 | Company user not found — `{"error":"…"}` | +| 409 | Cannot demote the last admin — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/events.md b/docs/api/routes/events.md index d818dba..7091240 100644 --- a/docs/api/routes/events.md +++ b/docs/api/routes/events.md @@ -48,6 +48,8 @@ emits `{ sender, brandId }`. | `printer` | `{}` | `POST /printer`, `PUT /printer/{id}`, `PATCH /printer/{id}`, `DELETE /printer/{id}` | | `printeragent` | `{}` | `PUT /printeragent`, `PATCH /printeragent/{id}`, `DELETE /printeragent/{id}`, printer agent connect/disconnect/state | | `agentprinter` | `{ printers }` or `{}` | `PATCH /agentprinter/{id}`, `PUT /agentprinter/order`, `DELETE /agentprinter/{id}`, agent state updates / printer discovery | +| `companyuser` | `{}` | `POST /companyuser`, `PATCH /companyuser/{id}`, `DELETE /companyuser/{id}` | +| `autoinvite` | `{}` | `POST /autoinvite`, `DELETE /autoinvite/{id}` | | `warehouse` | `{}` | `POST /warehouse`, `PUT /warehouse/{id}`, `PATCH /warehouse/{id}`, `DELETE /warehouse/{id}` | | `supplier` | `{}` | `POST /supplier`, `PATCH /supplier/{id}`, `DELETE /supplier/{id}` | | `vfs` | `{}` | VFS uploads and filesystem mutations such as `POST /vfs/upload`, `POST /vfs/mkdir`, `POST /vfs/rename`, `PUT /vfs/{*path}`, `DELETE /vfs/{*path}` | diff --git a/src/api/openapi.js b/src/api/openapi.js index 0b34644..049d9e9 100644 --- a/src/api/openapi.js +++ b/src/api/openapi.js @@ -59,6 +59,36 @@ * type: number * taxfreerate: * type: number + * role: + * type: string + * enum: [admin, user] + * description: The caller's role in this company + * CompanyUser: + * type: object + * required: [id, googleAuthId, email, role] + * properties: + * id: + * type: integer + * googleAuthId: + * type: integer + * email: + * type: string + * role: + * type: string + * enum: [admin, user] + * AutoInvite: + * type: object + * required: [id, email, role, createdAt] + * properties: + * id: + * type: integer + * email: + * type: string + * role: + * type: string + * enum: [admin, user] + * createdAt: + * type: string * SuccessResponse: * type: object * required: [ok] diff --git a/src/api/routes/auth/selectedCompany.js b/src/api/routes/auth/selectedCompany.js index 3359b50..b24dd25 100644 --- a/src/api/routes/auth/selectedCompany.js +++ b/src/api/routes/auth/selectedCompany.js @@ -53,6 +53,12 @@ export function register(app, basePath = '') { * application/json: * schema: * $ref: '#/components/schemas/Error' + * 403: + * description: Not authorized (the user is not a member of this company) + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' * 500: * description: Server error * content: @@ -79,6 +85,9 @@ export function register(app, basePath = '') { res.status(200).json({ user }); } catch (error) { + if (error.message === 'Not authorized') { + return res.status(403).json({ error: error.message }); + } console.error('Set selected company failed:', error); res.status(500).json({ error: 'Failed to set selected company' }); } diff --git a/src/api/routes/autoinvite.js b/src/api/routes/autoinvite.js new file mode 100644 index 0000000..dcfafbe --- /dev/null +++ b/src/api/routes/autoinvite.js @@ -0,0 +1,271 @@ +import * as db from '../../db/autoinvite.js'; +import { emit } from '../sse.js'; + +// #region Helpers + +function requireSelectedCompany(req) { + const companyId = req.user?.selectedCompanyId; + if (companyId == null) { + return { error: 'No company selected' }; + } + return { companyId }; +} + +function parsePositiveInt(val, field = 'id') { + const id = Number(val); + if (!Number.isInteger(id) || id <= 0) { + return { error: `${field} is required` }; + } + return { id }; +} + +function autoinviteErrorStatus(error) { + if (error.message === 'No company selected') return 400; + if (error.message === 'Not authorized') return 403; + if (error.message === 'Company not found') return 404; + if (error.message === 'Invitation not found') return 404; + if (error.message === 'Invalid role') return 400; + if (error.message === 'Invalid email') return 400; + if (error.message === 'User already registered') return 409; + if (error.message === 'Invitation already exists') return 409; + return 500; +} + +// #endregion + +/** + * Registers auto-invitation routes. All routes require the caller to be an + * admin of the selected company. + * @param {import('express').Application} app + * @param {string} [basePath=''] + */ +export function register(app, basePath = '') { + /** + * @openapi + * /autoinvite: + * get: + * tags: [AutoInvite] + * summary: List auto-invitations + * description: Lists pending auto-invitations of the selected company. Admin only. + * security: + * - cookieAuth: [] + * x-markdown: autoinvite/list.md + * responses: + * 200: + * description: Pending invitations + * content: + * application/json: + * schema: + * type: object + * properties: + * autoinvites: + * type: array + * items: + * $ref: '#/components/schemas/AutoInvite' + * 400: + * description: No company selected + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 401: + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 403: + * description: Not authorized (admin of the selected company required) + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + // #region GET /autoinvite + + app.get(`${basePath}/autoinvite`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + + try { + const autoinvites = await db.list(req.user.id); + res.status(200).json({ autoinvites }); + } catch (error) { + const status = autoinviteErrorStatus(error); + if (status === 500) console.error('List auto-invitations failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + // #endregion + + /** + * @openapi + * /autoinvite: + * post: + * tags: [AutoInvite] + * summary: Create auto-invitation + * description: | + * Registers an email for automatic membership. When a user with this email registers, + * the invitation is removed from the list and the user is added to the company with the given role. + * Emails that are already registered are rejected (409); use `POST /companyuser` for those. Admin only. + * security: + * - cookieAuth: [] + * x-signals: [autoinvite] + * x-markdown: autoinvite/create.md + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email] + * properties: + * email: + * type: string + * role: + * type: string + * enum: [admin, user] + * default: user + * example: + * email: newhire@example.com + * role: user + * responses: + * 201: + * description: Created invitation + * content: + * application/json: + * schema: + * type: object + * properties: + * autoinvite: + * type: object + * properties: + * id: + * type: integer + * email: + * type: string + * role: + * type: string + * enum: [admin, user] + * 400: + * description: No company selected, invalid email or invalid role + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 401: + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 403: + * description: Not authorized (admin of the selected company required) + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 409: + * description: User already registered or invitation already exists + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + // #region POST /autoinvite + + app.post(`${basePath}/autoinvite`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + + try { + const autoinvite = await db.add(req.user.id, { + email: req.body?.email, + role: req.body?.role, + }); + emit('autoinvite', { companyId: selected.companyId }); + res.status(201).json({ autoinvite }); + } catch (error) { + const status = autoinviteErrorStatus(error); + if (status === 500) console.error('Create auto-invitation failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + // #endregion + + /** + * @openapi + * /autoinvite/{id}: + * delete: + * tags: [AutoInvite] + * summary: Delete auto-invitation + * description: Deletes a pending auto-invitation of the selected company. Admin only. + * security: + * - cookieAuth: [] + * x-signals: [autoinvite] + * x-markdown: autoinvite/delete.md + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 204: + * description: Deleted + * 400: + * description: Invalid id or no company selected + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 401: + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 403: + * description: Not authorized (admin of the selected company required) + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 404: + * description: Invitation not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + // #region DELETE /autoinvite/:id + + app.delete(`${basePath}/autoinvite/:id`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + const parsedId = parsePositiveInt(req.params.id); + if (parsedId.error) { + return res.status(400).json({ error: parsedId.error }); + } + + try { + await db.remove(req.user.id, parsedId.id); + emit('autoinvite', { companyId: selected.companyId }); + res.sendStatus(204); + } catch (error) { + const status = autoinviteErrorStatus(error); + if (status === 500) console.error('Delete auto-invitation failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + // #endregion +} diff --git a/src/api/routes/companyuser.js b/src/api/routes/companyuser.js new file mode 100644 index 0000000..dd6f0cd --- /dev/null +++ b/src/api/routes/companyuser.js @@ -0,0 +1,385 @@ +import * as db from '../../db/companyuser.js'; +import { clearCachedUser } from '../middleware/auth.js'; +import { emit } from '../sse.js'; + +// #region Helpers + +function requireSelectedCompany(req) { + const companyId = req.user?.selectedCompanyId; + if (companyId == null) { + return { error: 'No company selected' }; + } + return { companyId }; +} + +function parsePositiveInt(val, field = 'id') { + const id = Number(val); + if (!Number.isInteger(id) || id <= 0) { + return { error: `${field} is required` }; + } + return { id }; +} + +function companyuserErrorStatus(error) { + if (error.message === 'No company selected') return 400; + if (error.message === 'Not authorized') return 403; + if (error.message === 'Company not found') return 404; + if (error.message === 'Company user not found') return 404; + if (error.message === 'User not found') return 404; + if (error.message === 'Invalid role') return 400; + if (error.message === 'Invalid email') return 400; + if (error.message === 'User already in company') return 409; + if (error.message === 'Cannot demote the last admin') return 409; + if (error.message === 'Cannot remove the last admin') return 409; + return 500; +} + +// #endregion + +/** + * Registers company user (membership) routes. All routes require the caller + * to be an admin of the selected company. + * @param {import('express').Application} app + * @param {string} [basePath=''] + */ +export function register(app, basePath = '') { + /** + * @openapi + * /companyuser: + * get: + * tags: [CompanyUser] + * summary: List company users + * description: Lists the members of the selected company with their role. Admin only. + * security: + * - cookieAuth: [] + * x-markdown: companyuser/list.md + * responses: + * 200: + * description: Company members + * content: + * application/json: + * schema: + * type: object + * properties: + * companyusers: + * type: array + * items: + * $ref: '#/components/schemas/CompanyUser' + * 400: + * description: No company selected + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 401: + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 403: + * description: Not authorized (admin of the selected company required) + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + // #region GET /companyuser + + app.get(`${basePath}/companyuser`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + + try { + const companyusers = await db.list(req.user.id); + res.status(200).json({ companyusers }); + } catch (error) { + const status = companyuserErrorStatus(error); + if (status === 500) console.error('List company users failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + // #endregion + + /** + * @openapi + * /companyuser: + * post: + * tags: [CompanyUser] + * summary: Add an existing user to the company + * description: | + * Adds an existing (registered) user, identified by email, to the selected company. Admin only. + * The added user can then switch to this company via `PUT /auth/selected-company`. + * For emails that are not registered yet use `POST /autoinvite`. + * security: + * - cookieAuth: [] + * x-signals: [companyuser] + * x-markdown: companyuser/create.md + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email] + * properties: + * email: + * type: string + * role: + * type: string + * enum: [admin, user] + * default: user + * example: + * email: colleague@example.com + * role: user + * responses: + * 201: + * description: Created company user + * content: + * application/json: + * schema: + * type: object + * properties: + * companyuser: + * $ref: '#/components/schemas/CompanyUser' + * 400: + * description: No company selected, invalid email or invalid role + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 401: + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 403: + * description: Not authorized (admin of the selected company required) + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 404: + * description: No registered user with this email + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 409: + * description: User already in company + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + // #region POST /companyuser + + app.post(`${basePath}/companyuser`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + + try { + const companyuser = await db.addByEmail(req.user.id, { + email: req.body?.email, + role: req.body?.role, + }); + emit('companyuser', { companyId: selected.companyId }); + res.status(201).json({ companyuser }); + } catch (error) { + const status = companyuserErrorStatus(error); + if (status === 500) console.error('Add company user failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + // #endregion + + /** + * @openapi + * /companyuser/{id}: + * patch: + * tags: [CompanyUser] + * summary: Change a company user's role + * description: Changes the role of a member of the selected company. The last admin cannot be demoted. Admin only. + * security: + * - cookieAuth: [] + * x-signals: [companyuser] + * x-markdown: companyuser/update.md + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [role] + * properties: + * role: + * type: string + * enum: [admin, user] + * responses: + * 200: + * description: Updated company user + * content: + * application/json: + * schema: + * type: object + * properties: + * companyuser: + * type: object + * properties: + * id: + * type: integer + * googleAuthId: + * type: integer + * role: + * type: string + * enum: [admin, user] + * 400: + * description: Invalid id, role or no company selected + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 401: + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 403: + * description: Not authorized (admin of the selected company required) + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 404: + * description: Company user not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 409: + * description: Cannot demote the last admin + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + // #region PATCH /companyuser/:id + + app.patch(`${basePath}/companyuser/:id`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + const parsedId = parsePositiveInt(req.params.id); + if (parsedId.error) { + return res.status(400).json({ error: parsedId.error }); + } + + try { + const companyuser = await db.setRole(req.user.id, parsedId.id, req.body?.role); + clearCachedUser(companyuser.googleAuthId); + emit('companyuser', { companyId: selected.companyId }); + res.status(200).json({ companyuser }); + } catch (error) { + const status = companyuserErrorStatus(error); + if (status === 500) console.error('Update company user failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + // #endregion + + /** + * @openapi + * /companyuser/{id}: + * delete: + * tags: [CompanyUser] + * summary: Remove a user from the company + * description: | + * Removes a member from the selected company. History entries stay but lose their author link; + * if the removed user had this company selected, their selection is cleared. + * The last admin cannot be removed. Admin only. + * security: + * - cookieAuth: [] + * x-signals: [companyuser] + * x-markdown: companyuser/delete.md + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 204: + * description: Removed + * 400: + * description: Invalid id or no company selected + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 401: + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 403: + * description: Not authorized (admin of the selected company required) + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 404: + * description: Company user not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 409: + * description: Cannot remove the last admin + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + // #region DELETE /companyuser/:id + + app.delete(`${basePath}/companyuser/:id`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + const parsedId = parsePositiveInt(req.params.id); + if (parsedId.error) { + return res.status(400).json({ error: parsedId.error }); + } + + try { + const removed = await db.remove(req.user.id, parsedId.id); + clearCachedUser(removed.googleAuthId); + emit('companyuser', { companyId: selected.companyId }); + res.sendStatus(204); + } catch (error) { + const status = companyuserErrorStatus(error); + if (status === 500) console.error('Remove company user failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + // #endregion +} diff --git a/src/api/routes/events.js b/src/api/routes/events.js index c69196f..0700232 100644 --- a/src/api/routes/events.js +++ b/src/api/routes/events.js @@ -57,6 +57,8 @@ export function register(app, basePath = '') { * | `printer` | `{}` | `POST /printer`, `PUT /printer/{id}`, `PATCH /printer/{id}`, `DELETE /printer/{id}` | * | `printeragent` | `{}` | `PUT /printeragent`, `PATCH /printeragent/{id}`, `DELETE /printeragent/{id}`, printer agent connect/disconnect/state | * | `agentprinter` | `{ printers }` or `{}` | `PATCH /agentprinter/{id}`, `PUT /agentprinter/order`, `DELETE /agentprinter/{id}`, agent state updates / printer discovery | + * | `companyuser` | `{}` | `POST /companyuser`, `PATCH /companyuser/{id}`, `DELETE /companyuser/{id}` | + * | `autoinvite` | `{}` | `POST /autoinvite`, `DELETE /autoinvite/{id}` | * | `warehouse` | `{}` | `POST /warehouse`, `PUT /warehouse/{id}`, `PATCH /warehouse/{id}`, `DELETE /warehouse/{id}` | * | `supplier` | `{}` | `POST /supplier`, `PATCH /supplier/{id}`, `DELETE /supplier/{id}` | * | `vfs` | `{}` | VFS uploads and filesystem mutations such as `POST /vfs/upload`, `POST /vfs/mkdir`, `POST /vfs/rename`, `PUT /vfs/{*path}`, `DELETE /vfs/{*path}` | @@ -95,6 +97,10 @@ export function register(app, basePath = '') { * payload: "{}" * - name: propertyname * payload: "{}" + * - name: companyuser + * payload: "{}" + * - name: autoinvite + * payload: "{}" * - name: labeltemplate * payload: "{}" * - name: printer diff --git a/src/db/autoinvite.js b/src/db/autoinvite.js new file mode 100644 index 0000000..d0e4a3e --- /dev/null +++ b/src/db/autoinvite.js @@ -0,0 +1,16 @@ +import { getStore } from './store.js'; + +export async function list(googleAuthId) { + const store = await getStore(); + return store.autoinvite.list(googleAuthId); +} + +export async function add(googleAuthId, data) { + const store = await getStore(); + return store.autoinvite.add(googleAuthId, data); +} + +export async function remove(googleAuthId, id) { + const store = await getStore(); + return store.autoinvite.remove(googleAuthId, id); +} diff --git a/src/db/companyuser.js b/src/db/companyuser.js new file mode 100644 index 0000000..76b3b2a --- /dev/null +++ b/src/db/companyuser.js @@ -0,0 +1,21 @@ +import { getStore } from './store.js'; + +export async function list(googleAuthId) { + const store = await getStore(); + return store.companyuser.list(googleAuthId); +} + +export async function addByEmail(googleAuthId, data) { + const store = await getStore(); + return store.companyuser.addByEmail(googleAuthId, data); +} + +export async function setRole(googleAuthId, id, role) { + const store = await getStore(); + return store.companyuser.setRole(googleAuthId, id, role); +} + +export async function remove(googleAuthId, id) { + const store = await getStore(); + return store.companyuser.remove(googleAuthId, id); +} diff --git a/src/db/store.js b/src/db/store.js index b2cbc53..bc78843 100644 --- a/src/db/store.js +++ b/src/db/store.js @@ -14,6 +14,7 @@ let schemaVersion; const STORE_NAMESPACES = [ 'companyuser', + 'autoinvite', 'company', 'category', 'article',