mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 09:00:22 +01:00
Feature: Multi-user request system (#615)
- Adds a comprehensive multi-user request system to the existing download flow - Request configuration is policy based. Configure global settings for content type, or narrow down policy for specific sources (E.g. allow direct downloads, set prowlarr to request only, block IRC completely, etc). - Global policy configuration and per-user overrides for tailored configs - Replaced downloads sidebar with ActivitySidebar, combining active downloads with requests. Admin management of user requests is done here, and admins have view of downloads from all users. Sidebar can now be pinned. - Request either a standard book or a specific release. Release-requests are used if you permit one source differently than the other. On book-level requests, admins pick the specific file to be attached to the fulfilled request. - Users can request books with a note This is WIP so some features are still not complete (notifications, more automatic release selection, among others).
This commit is contained in:
@@ -190,6 +190,31 @@ HeadingField(
|
||||
)
|
||||
```
|
||||
|
||||
### CustomComponentField
|
||||
|
||||
Render a frontend-registered custom settings component while still using the
|
||||
decorator-based schema.
|
||||
|
||||
```python
|
||||
from shelfmark.core.settings_registry import CustomComponentField
|
||||
|
||||
CustomComponentField(
|
||||
key="request_policy_editor",
|
||||
component="request_policy_grid", # frontend registry key
|
||||
label="Request Policy Rules",
|
||||
description="Custom editor for policy defaults and matrix rules.",
|
||||
value_fields=[
|
||||
SelectField(key="REQUEST_POLICY_DEFAULT_EBOOK", label="Default Ebook Mode", default="download"),
|
||||
SelectField(key="REQUEST_POLICY_DEFAULT_AUDIOBOOK", label="Default Audiobook Mode", default="download"),
|
||||
TableField(key="REQUEST_POLICY_RULES", label="Rules", columns=_rule_columns, default=[]),
|
||||
],
|
||||
wrap_in_field_wrapper=True, # use standard FieldWrapper label/description layout
|
||||
)
|
||||
```
|
||||
|
||||
When `value_fields` is provided, those backing fields are included in
|
||||
serialization/save/validation automatically and are hidden from the default renderer.
|
||||
|
||||
## Common Field Properties
|
||||
|
||||
All field types support these common properties:
|
||||
@@ -206,6 +231,7 @@ All field types support these common properties:
|
||||
| `requires_restart` | `bool` | `False` | Whether changes require container restart |
|
||||
| `show_when` | `dict` | `None` | Conditional visibility (see below) |
|
||||
| `disabled_when` | `dict` | `None` | Conditional disable (see below) |
|
||||
| `hidden_in_ui` | `bool` | `False` | Hide from default renderer but keep in schema/save path |
|
||||
|
||||
## Conditional Visibility
|
||||
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
../baseline-browser-mapping/dist/cli.js
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "shelfmark",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.9.19",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
||||
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+463
@@ -0,0 +1,463 @@
|
||||
# [`baseline-browser-mapping`](https://github.com/web-platform-dx/web-features/packages/baseline-browser-mapping)
|
||||
|
||||
By the [W3C WebDX Community Group](https://www.w3.org/community/webdx/) and contributors.
|
||||
|
||||
`baseline-browser-mapping` provides:
|
||||
|
||||
- An `Array` of browsers compatible with Baseline Widely available and Baseline year feature sets via the [`getCompatibleVersions()` function](#get-baseline-widely-available-browser-versions-or-baseline-year-browser-versions).
|
||||
- An `Array`, `Object` or `CSV` as a string describing the Baseline feature set support of all browser versions included in the module's data set via the [`getAllVersions()` function](#get-data-for-all-browser-versions).
|
||||
|
||||
You can use `baseline-browser-mapping` to help you determine minimum browser version support for your chosen Baseline feature set; or to analyse the level of support for different Baseline feature sets in your site's traffic by joining the data with your analytics data.
|
||||
|
||||
## Install for local development
|
||||
|
||||
To install the package, run:
|
||||
|
||||
`npm install --save-dev baseline-browser-mapping`
|
||||
|
||||
`baseline-browser-mapping` depends on `web-features` and `@mdn/browser-compat-data` for core browser version selection, but the data is pre-packaged and minified. This package checks for updates to those modules and the supported [downstream browsers](#downstream-browsers) on a daily basis and is updated frequently. Consider adding a script to your `package.json` to update `baseline-browser-mapping` and using it as part of your build process to ensure your data is as up to date as possible:
|
||||
|
||||
```javascript
|
||||
"scripts": [
|
||||
"refresh-baseline-browser-mapping": "npm i --save-dev baseline-browser-mapping@latest"
|
||||
]
|
||||
```
|
||||
|
||||
The minimum supported NodeJS version for `baseline-browser-mapping` is v8 in alignment with `browserslist`. For NodeJS versions earlier than v13.2, the [`require('baseline-browser-mapping')`](https://nodejs.org/api/modules.html#requireid) syntax should be used to import the module.
|
||||
|
||||
## Keeping `baseline-browser-mapping` up to date
|
||||
|
||||
If you are only using this module to generate minimum browser versions for Baseline Widely available or Baseline year feature sets, you don't need to update this module frequently, as the backward looking data is reasonably stable.
|
||||
|
||||
However, if you are targeting Newly available, using the [`getAllVersions()`](#get-data-for-all-browser-versions) function or heavily relying on the data for downstream browsers, you should update this module more frequently. If you target a feature cut off date within the last two months and your installed version of `baseline-browser-mapping` has data that is more than 2 months old, you will receive a console warning advising you to update to the latest version when you call `getCompatibleVersions()` or `getAllVersions()`.
|
||||
|
||||
If you want to suppress these warnings you can use the `suppressWarnings: true` option in the configuration object passed to `getCompatibleVersions()` or `getAllVersions()`. Alternatively, you can use the `BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA=true` environment variable when running your build process. This module also respects the `BROWSERSLIST_IGNORE_OLD_DATA=true` environment variable. Environment variables can also be provided in a `.env` file from Node 20 onwards; however, this module does not load .env files automatically to avoid conflicts with other libraries with different requirements. You will need to use `process.loadEnvFile()` or a library like `dotenv` to load .env files before `baseline-browser-mapping` is called.
|
||||
|
||||
If you want to ensure [reproducible builds](https://www.wikiwand.com/en/articles/Reproducible_builds), we strongly recommend using the `widelyAvailableOnDate` option to fix the Widely available date on a per build basis to ensure dependent tools provide the same output and you do not produce data staleness warnings. If you are using [`browserslist`](https://github.com/browserslist/browserslist) to target Baseline Widely available, consider automatically updating your `browserslist` configuration in `package.json` or `.browserslistrc` to `baseline widely available on {YYYY-MM-DD}` as part of your build process to ensure the same or sufficiently similar list of minimum browsers is reproduced for historical builds.
|
||||
|
||||
## Importing `baseline-browser-mapping`
|
||||
|
||||
This module exposes two functions: `getCompatibleVersions()` and `getAllVersions()`, both which can be imported directly from `baseline-browser-mapping`:
|
||||
|
||||
```javascript
|
||||
import {
|
||||
getCompatibleVersions,
|
||||
getAllVersions,
|
||||
} from "baseline-browser-mapping";
|
||||
```
|
||||
|
||||
If you want to load the script and data directly in a web page without hosting it yourself, consider using a CDN:
|
||||
|
||||
```html
|
||||
<script type="module">
|
||||
import {
|
||||
getCompatibleVersions,
|
||||
getAllVersions,
|
||||
} from "https://cdn.jsdelivr.net/npm/baseline-browser-mapping";
|
||||
</script>
|
||||
```
|
||||
|
||||
## Get Baseline Widely available browser versions or Baseline year browser versions
|
||||
|
||||
To get the current list of minimum browser versions compatible with Baseline Widely available features from the core browser set, call the `getCompatibleVersions()` function:
|
||||
|
||||
```javascript
|
||||
getCompatibleVersions();
|
||||
```
|
||||
|
||||
Executed on 7th March 2025, the above code returns the following browser versions:
|
||||
|
||||
```javascript
|
||||
[
|
||||
{ browser: "chrome", version: "105", release_date: "2022-09-02" },
|
||||
{
|
||||
browser: "chrome_android",
|
||||
version: "105",
|
||||
release_date: "2022-09-02",
|
||||
},
|
||||
{ browser: "edge", version: "105", release_date: "2022-09-02" },
|
||||
{ browser: "firefox", version: "104", release_date: "2022-08-23" },
|
||||
{
|
||||
browser: "firefox_android",
|
||||
version: "104",
|
||||
release_date: "2022-08-23",
|
||||
},
|
||||
{ browser: "safari", version: "15.6", release_date: "2022-09-02" },
|
||||
{
|
||||
browser: "safari_ios",
|
||||
version: "15.6",
|
||||
release_date: "2022-09-02",
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The minimum versions of each browser are not necessarily the final release before the Widely available cutoff date of `TODAY - 30 MONTHS`. Some earlier versions will have supported the full Widely available feature set.
|
||||
|
||||
### `getCompatibleVersions()` configuration options
|
||||
|
||||
`getCompatibleVersions()` accepts an `Object` as an argument with configuration options. The defaults are as follows:
|
||||
|
||||
```javascript
|
||||
{
|
||||
targetYear: undefined,
|
||||
widelyAvailableOnDate: undefined,
|
||||
includeDownstreamBrowsers: false,
|
||||
listAllCompatibleVersions: false,
|
||||
suppressWarnings: false
|
||||
}
|
||||
```
|
||||
|
||||
#### `targetYear`
|
||||
|
||||
The `targetYear` option returns the minimum browser versions compatible with all **Baseline Newly available** features at the end of the specified calendar year. For example, calling:
|
||||
|
||||
```javascript
|
||||
getCompatibleVersions({
|
||||
targetYear: 2020,
|
||||
});
|
||||
```
|
||||
|
||||
Returns the following versions:
|
||||
|
||||
```javascript
|
||||
[
|
||||
{ browser: "chrome", version: "87", release_date: "2020-11-19" },
|
||||
{
|
||||
browser: "chrome_android",
|
||||
version: "87",
|
||||
release_date: "2020-11-19",
|
||||
},
|
||||
{ browser: "edge", version: "87", release_date: "2020-11-19" },
|
||||
{ browser: "firefox", version: "83", release_date: "2020-11-17" },
|
||||
{
|
||||
browser: "firefox_android",
|
||||
version: "83",
|
||||
release_date: "2020-11-17",
|
||||
},
|
||||
{ browser: "safari", version: "14", release_date: "2020-09-16" },
|
||||
{ browser: "safari_ios", version: "14", release_date: "2020-09-16" },
|
||||
];
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The minimum version of each browser is not necessarily the final version released in that calendar year. In the above example, Firefox 84 was the final version released in 2020; however Firefox 83 supported all of the features that were interoperable at the end of 2020.
|
||||
> [!WARNING]
|
||||
> You cannot use `targetYear` and `widelyAavailableDate` together. Please only use one of these options at a time.
|
||||
|
||||
#### `widelyAvailableOnDate`
|
||||
|
||||
The `widelyAvailableOnDate` option returns the minimum versions compatible with Baseline Widely available on a specified date in the format `YYYY-MM-DD`:
|
||||
|
||||
```javascript
|
||||
getCompatibleVersions({
|
||||
widelyAvailableOnDate: `2023-04-05`,
|
||||
});
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> This option is useful if you provide a versioned library that targets Baseline Widely available on each version's release date and you need to provide a statement on minimum supported browser versions in your documentation.
|
||||
|
||||
#### `includeDownstreamBrowsers`
|
||||
|
||||
Setting `includeDownstreamBrowsers` to `true` will include browsers outside of the Baseline core browser set where it is possible to map those browsers to an upstream Chromium or Gecko version:
|
||||
|
||||
```javascript
|
||||
getCompatibleVersions({
|
||||
includeDownstreamBrowsers: true,
|
||||
});
|
||||
```
|
||||
|
||||
For more information on downstream browsers, see [the section on downstream browsers](#downstream-browsers) below.
|
||||
|
||||
#### `includeKaiOS`
|
||||
|
||||
KaiOS is an operating system and app framework based on the Gecko engine from Firefox. KaiOS is based on the Gecko engine and feature support can be derived from the upstream Gecko version that each KaiOS version implements. However KaiOS requires other considerations beyond feature compatibility to ensure a good user experience as it runs on device types that do not have either mouse and keyboard or touch screen input in the way that all the other browsers supported by this module do.
|
||||
|
||||
```javascript
|
||||
getCompatibleVersions({
|
||||
includeDownstreamBrowsers: true,
|
||||
includeKaiOS: true,
|
||||
});
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Including KaiOS requires you to include all downstream browsers using the `includeDownstreamBrowsers` option.
|
||||
|
||||
#### `listAllCompatibleVersions`
|
||||
|
||||
Setting `listAllCompatibleVersions` to true will include the minimum versions of each compatible browser, and all the subsequent versions:
|
||||
|
||||
```javascript
|
||||
getCompatibleVersions({
|
||||
listAllCompatibleVersions: true,
|
||||
});
|
||||
```
|
||||
|
||||
#### `suppressWarnings`
|
||||
|
||||
Setting `suppressWarnings` to `true` will suppress the console warning about old data:
|
||||
|
||||
```javascript
|
||||
getCompatibleVersions({
|
||||
suppressWarnings: true,
|
||||
});
|
||||
```
|
||||
|
||||
## Get data for all browser versions
|
||||
|
||||
You may want to obtain data on all the browser versions available in this module for use in an analytics solution or dashboard. To get details of each browser version's level of Baseline support, call the `getAllVersions()` function:
|
||||
|
||||
```javascript
|
||||
import { getAllVersions } from "baseline-browser-mapping";
|
||||
|
||||
getAllVersions();
|
||||
```
|
||||
|
||||
By default, this function returns an `Array` of `Objects` and excludes downstream browsers:
|
||||
|
||||
```javascript
|
||||
[
|
||||
...
|
||||
{
|
||||
browser: "firefox_android", // Browser name
|
||||
version: "125", // Browser version
|
||||
release_date: "2024-04-16", // Release date
|
||||
year: 2023, // Baseline year feature set the version supports
|
||||
wa_compatible: true // Whether the browser version supports Widely available
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
For browser versions in `@mdn/browser-compat-data` that were released before Baseline can be defined, i.e. Baseline 2015, the `year` property is always the string: `"pre_baseline"`.
|
||||
|
||||
### Understanding which browsers support Newly available features
|
||||
|
||||
You may want to understand which recent browser versions support all Newly available features. You can replace the `wa_compatible` property with a `supports` property using the `useSupport` option:
|
||||
|
||||
```javascript
|
||||
getAllVersions({
|
||||
useSupports: true,
|
||||
});
|
||||
```
|
||||
|
||||
The `supports` property is optional and has two possible values:
|
||||
|
||||
- `widely` for browser versions that support all Widely available features.
|
||||
- `newly` for browser versions that support all Newly available features.
|
||||
|
||||
Browser versions that do not support Widely or Newly available will not include the `support` property in the `array` or `object` outputs, and in the CSV output, the `support` column will contain an empty string. Browser versions that support all Newly available features also support all Widely available features.
|
||||
|
||||
### `getAllVersions()` Configuration options
|
||||
|
||||
`getAllVersions()` accepts an `Object` as an argument with configuration options. The defaults are as follows:
|
||||
|
||||
```javascript
|
||||
{
|
||||
includeDownstreamBrowsers: false,
|
||||
outputFormat: "array",
|
||||
suppressWarnings: false
|
||||
}
|
||||
```
|
||||
|
||||
#### `includeDownstreamBrowsers` (in `getAllVersions()` output)
|
||||
|
||||
As with `getCompatibleVersions()`, you can set `includeDownstreamBrowsers` to `true` to include the Chromium and Gecko downstream browsers [listed below](#list-of-downstream-browsers).
|
||||
|
||||
```javascript
|
||||
getAllVersions({
|
||||
includeDownstreamBrowsers: true,
|
||||
});
|
||||
```
|
||||
|
||||
Downstream browsers include the same properties as core browsers, as well as the `engine`they use and `engine_version`, for example:
|
||||
|
||||
```javascript
|
||||
[
|
||||
...
|
||||
{
|
||||
browser: "samsunginternet_android",
|
||||
version: "27.0",
|
||||
release_date: "2024-11-06",
|
||||
engine: "Blink",
|
||||
engine_version: "125",
|
||||
year: 2023,
|
||||
supports: "widely"
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
#### `includeKaiOS` (in `getAllVersions()` output)
|
||||
|
||||
As with `getCompatibleVersions()` you can include KaiOS in your output. The same requirement to have `includeDownstreamBrowsers: true` applies.
|
||||
|
||||
```javascript
|
||||
getAllVersions({
|
||||
includeDownstreamBrowsers: true,
|
||||
includeKaiOS: true,
|
||||
});
|
||||
```
|
||||
|
||||
#### `suppressWarnings` (in `getAllVersions()` output)
|
||||
|
||||
As with `getCompatibleVersions()`, you can set `suppressWarnings` to `true` to suppress the console warning about old data:
|
||||
|
||||
```javascript
|
||||
getAllVersions({
|
||||
suppressWarnings: true,
|
||||
});
|
||||
```
|
||||
|
||||
#### `outputFormat`
|
||||
|
||||
By default, this function returns an `Array` of `Objects` which can be manipulated in Javascript or output to JSON.
|
||||
|
||||
To return an `Object` that nests keys , set `outputFormat` to `object`:
|
||||
|
||||
```javascript
|
||||
getAllVersions({
|
||||
outputFormat: "object",
|
||||
});
|
||||
```
|
||||
|
||||
In thise case, `getAllVersions()` returns a nested object with the browser [IDs listed below](#list-of-downstream-browsers) as keys, and versions as keys within them:
|
||||
|
||||
```javascript
|
||||
{
|
||||
"chrome": {
|
||||
"53": {
|
||||
"year": 2016,
|
||||
"release_date": "2016-09-07"
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Downstream browsers will include extra fields for `engine` and `engine_versions`
|
||||
|
||||
```javascript
|
||||
{
|
||||
...
|
||||
"webview_android": {
|
||||
"53": {
|
||||
"year": 2016,
|
||||
"release_date": "2016-09-07",
|
||||
"engine": "Blink",
|
||||
"engine_version": "53"
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
To return a `String` in CSV format, set `outputFormat` to `csv`:
|
||||
|
||||
```javascript
|
||||
getAllVersions({
|
||||
outputFormat: "csv",
|
||||
});
|
||||
```
|
||||
|
||||
`getAllVersions` returns a `String` with a header row and comma-separated values for each browser version that you can write to a file or pass to another service. Core browsers will have "NULL" as the value for their `engine` and `engine_version`:
|
||||
|
||||
```csv
|
||||
"browser","version","year","supports","release_date","engine","engine_version"
|
||||
...
|
||||
"chrome","24","pre_baseline","","2013-01-10","NULL","NULL"
|
||||
...
|
||||
"chrome","53","2016","","2016-09-07","NULL","NULL"
|
||||
...
|
||||
"firefox","135","2024","widely","2025-02-04","NULL","NULL"
|
||||
"firefox","136","2024","newly","2025-03-04","NULL","NULL"
|
||||
...
|
||||
"ya_android","20.12","2020","year_only","2020-12-20","Blink","87"
|
||||
...
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The above example uses `"includeDownstreamBrowsers": true`
|
||||
|
||||
### Static resources
|
||||
|
||||
The outputs of `getAllVersions()` are available as JSON or CSV files generated on a daily basis and hosted on GitHub pages:
|
||||
|
||||
- Core browsers only
|
||||
- [Array](https://web-platform-dx.github.io/baseline-browser-mapping/all_versions_array.json)
|
||||
- [Object](https://web-platform-dx.github.io/baseline-browser-mapping/all_versions_object.json)
|
||||
- [CSV](https://web-platform-dx.github.io/baseline-browser-mapping/all_versions.csv)
|
||||
- Core browsers only, with `supports` property
|
||||
- [Array](https://web-platform-dx.github.io/baseline-browser-mapping/all_versions_array_with_supports.json)
|
||||
- [Object](https://web-platform-dx.github.io/baseline-browser-mapping/all_versions_object_with_supports.json)
|
||||
- [CSV](https://web-platform-dx.github.io/baseline-browser-mapping/all_versions_with_supports.csv)
|
||||
- Including downstream browsers
|
||||
- [Array](https://web-platform-dx.github.io/baseline-browser-mapping/with_downstream/all_versions_array.json)
|
||||
- [Object](https://web-platform-dx.github.io/baseline-browser-mapping/with_downstream/all_versions_object.json)
|
||||
- [CSV](https://web-platform-dx.github.io/baseline-browser-mapping/with_downstream/all_versions.csv)
|
||||
- Including downstream browsers with `supports` property
|
||||
- [Array](https://web-platform-dx.github.io/baseline-browser-mapping/with_downstream/all_versions_array_with_supports.json)
|
||||
- [Object](https://web-platform-dx.github.io/baseline-browser-mapping/with_downstream/all_versions_object_with_supports.json)
|
||||
- [CSV](https://web-platform-dx.github.io/baseline-browser-mapping/with_downstream/all_versions_with_supports.csv)
|
||||
|
||||
These files are updated on a daily basis.
|
||||
|
||||
## CLI
|
||||
|
||||
`baseline-browser-mapping` includes a command line interface that exposes the same data and options as the `getCompatibleVersions()` function. To learn more about using the CLI, run:
|
||||
|
||||
```sh
|
||||
npx baseline-browser-mapping --help
|
||||
```
|
||||
|
||||
## Downstream browsers
|
||||
|
||||
### Limitations
|
||||
|
||||
The browser versions in this module come from two different sources:
|
||||
|
||||
- MDN's `browser-compat-data` module.
|
||||
- Parsed user agent strings provided by [useragents.io](https://useragents.io/)
|
||||
|
||||
MDN `browser-compat-data` is an authoritative source of information for the browsers it contains. The release dates for the Baseline core browser set and the mapping of downstream browsers to Chromium versions should be considered accurate.
|
||||
|
||||
Browser mappings from useragents.io are provided on a best effort basis. They assume that browser vendors are accurately stating the Chromium version they have implemented. The initial set of version mappings was derived from a bulk export in November 2024. This version was iterated over with a Regex match looking for a major Chrome version and a corresponding version of the browser in question, e.g.:
|
||||
|
||||
`Mozilla/5.0 (Linux; U; Android 10; en-US; STK-L21 Build/HUAWEISTK-L21) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/100.0.4896.58 UCBrowser/13.8.2.1324 Mobile Safari/537.36`
|
||||
|
||||
Shows UC Browser Mobile 13.8 implementing Chromium 100, and:
|
||||
|
||||
`Mozilla/5.0 (Linux; arm_64; Android 11; Redmi Note 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.123 YaBrowser/24.10.2.123.00 SA/3 Mobile Safari/537.36`
|
||||
|
||||
Shows Yandex Browser Mobile 24.10 implementing Chromium 128. The Chromium version from this string is mapped to the corresponding Chrome version from MDN `browser-compat-data`.
|
||||
|
||||
> [!NOTE]
|
||||
> Where possible, approximate release dates have been included based on useragents.io "first seen" data. useragents.io does not have "first seen" dates prior to June 2020. However, these browsers' Baseline compatibility is determined by their Chromium or Gecko version, so their release dates are more informative than critical.
|
||||
|
||||
This data is updated on a daily basis using a [script](https://github.com/web-platform-dx/web-features/tree/main/scripts/refresh-downstream.ts) triggered by a GitHub [action](https://github.com/web-platform-dx/web-features/tree/main/.github/workflows/refresh_downstream.yml). Useragents.io provides a private API for this module which exposes the last 7 days of newly seen user agents for the currently tracked browsers. If a new major version of one of the tracked browsers is encountered with a Chromium version that meets or exceeds the previous latest version of that browser, it is added to the [src/data/downstream-browsers.json](src/data/downstream-browsers.json) file with the date it was first seen by useragents.io as its release date.
|
||||
|
||||
KaiOS is an exception - its upstream version mappings are handled separately from the other browsers because they happen very infrequently.
|
||||
|
||||
### List of downstream browsers
|
||||
|
||||
| Browser | ID | Core | Source |
|
||||
| --------------------- | ------------------------- | ------- | ------------------------- |
|
||||
| Chrome | `chrome` | `true` | MDN `browser-compat-data` |
|
||||
| Chrome for Android | `chrome_android` | `true` | MDN `browser-compat-data` |
|
||||
| Edge | `edge` | `true` | MDN `browser-compat-data` |
|
||||
| Firefox | `firefox` | `true` | MDN `browser-compat-data` |
|
||||
| Firefox for Android | `firefox_android` | `true` | MDN `browser-compat-data` |
|
||||
| Safari | `safari` | `true` | MDN `browser-compat-data` |
|
||||
| Safari on iOS | `safari_ios` | `true` | MDN `browser-compat-data` |
|
||||
| Opera | `opera` | `false` | MDN `browser-compat-data` |
|
||||
| Opera Android | `opera_android` | `false` | MDN `browser-compat-data` |
|
||||
| Samsung Internet | `samsunginternet_android` | `false` | MDN `browser-compat-data` |
|
||||
| WebView Android | `webview_android` | `false` | MDN `browser-compat-data` |
|
||||
| QQ Browser Mobile | `qq_android` | `false` | useragents.io |
|
||||
| UC Browser Mobile | `uc_android` | `false` | useragents.io |
|
||||
| Yandex Browser Mobile | `ya_android` | `false` | useragents.io |
|
||||
| KaiOS | `kai_os` | `false` | Manual |
|
||||
| Facebook for Android | `facebook_android` | `false` | useragents.io |
|
||||
| Instagram for Android | `instagram_android` | `false` | useragents.io |
|
||||
|
||||
> [!NOTE]
|
||||
> All the non-core browsers currently included implement Chromium or Gecko. Their inclusion in any of the above methods is based on the Baseline feature set supported by the Chromium or Gecko version they implement, not their release date.
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "baseline-browser-mapping",
|
||||
"main": "./dist/index.cjs",
|
||||
"version": "2.9.19",
|
||||
"description": "A library for obtaining browser versions with their maximum supported Baseline feature set and Widely Available status.",
|
||||
"exports": {
|
||||
".": {
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./legacy": {
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"jsdelivr": "./dist/index.js",
|
||||
"files": [
|
||||
"dist/*",
|
||||
"!dist/scripts/*",
|
||||
"LICENSE.txt",
|
||||
"README.md"
|
||||
],
|
||||
"types": "./dist/index.d.ts",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"fix-cli-permissions": "output=$(npx baseline-browser-mapping 2>&1); path=$(printf '%s\n' \"$output\" | sed -n 's/^.*: \\(.*\\): Permission denied$/\\1/p; t; s/^\\(.*\\): Permission denied$/\\1/p'); if [ -n \"$path\" ]; then echo \"Permission denied for: $path\"; echo \"Removing $path ...\"; rm -rf \"$path\"; else echo \"$output\"; fi",
|
||||
"test:format": "npx prettier --check .",
|
||||
"test:lint": "npx eslint .",
|
||||
"test:jasmine": "npx jasmine",
|
||||
"test:jasmine-browser": "npx jasmine-browser-runner runSpecs --config ./spec/support/jasmine-browser.js",
|
||||
"test": "npm run build && npm run fix-cli-permissions && npm run test:format && npm run test:lint && npm run test:jasmine && npm run test:jasmine-browser",
|
||||
"build": "rm -rf dist; npx prettier . --write; rollup -c; rm -rf ./dist/scripts/expose-data.d.ts ./dist/cli.d.ts",
|
||||
"refresh-downstream": "npx tsx scripts/refresh-downstream.ts",
|
||||
"refresh-static": "npx tsx scripts/refresh-static.ts",
|
||||
"update-data-file": "npx tsx scripts/update-data-file.ts; npx prettier ./src/data/data.js --write",
|
||||
"update-data-dependencies": "npm i @mdn/browser-compat-data@latest web-features@latest -D",
|
||||
"check-data-changes": "git diff --name-only | grep -q '^src/data/data.js$' && echo 'changes-available=TRUE' || echo 'changes-available=FALSE'"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@mdn/browser-compat-data": "^7.2.5",
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"@rollup/plugin-typescript": "^12.1.3",
|
||||
"@types/node": "^22.15.17",
|
||||
"eslint-plugin-new-with-error": "^5.0.0",
|
||||
"jasmine": "^5.8.0",
|
||||
"jasmine-browser-runner": "^3.0.0",
|
||||
"jasmine-spec-reporter": "^7.0.0",
|
||||
"prettier": "^3.5.3",
|
||||
"rollup": "^4.44.0",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.35.0",
|
||||
"web-features": "^3.14.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/web-platform-dx/baseline-browser-mapping.git"
|
||||
}
|
||||
}
|
||||
Generated
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "shelfmark",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"baseline-browser-mapping": "^2.9.19"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.9.19",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
||||
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"baseline-browser-mapping": "^2.9.19"
|
||||
}
|
||||
}
|
||||
@@ -151,21 +151,15 @@ If you need Cloudflare bypass with the Lite image, configure an external resolve
|
||||
|
||||
## 🔐 Authentication
|
||||
|
||||
Authentication is optional but recommended for shared or exposed instances. Four authentication methods are available in Settings:
|
||||
Authentication is optional but recommended for shared or exposed instances. Three authentication methods are available in Settings:
|
||||
|
||||
**1. Built-in Username/Password**
|
||||
**1. Single Username/Password**
|
||||
|
||||
Multi-user support with admin user management. The first user is always admin. Admins can create additional users, set per-user download destinations, and manage roles.
|
||||
|
||||
**2. OpenID Connect (OIDC)**
|
||||
|
||||
Integrate with any OIDC provider (Authentik, Keycloak, Pocket ID, etc.) for SSO. Supports auto-provisioning, group-based admin mapping, and per-user download settings. Configure your provider's discovery URL, client ID, and client secret in Settings.
|
||||
|
||||
**3. Proxy (Forward) Authentication**
|
||||
**2. Proxy (Forward) Authentication**
|
||||
|
||||
Proxy auth trusts headers set by your reverse proxy (e.g. `X-Auth-User`). Ensure Shelfmark is not directly exposed, and configure your proxy to strip/overwrite these headers for all inbound requests.
|
||||
|
||||
**4. Calibre-Web Database**
|
||||
**3. Calibre-Web Database**
|
||||
|
||||
If you're running Calibre-Web, you can reuse its user database by mounting it:
|
||||
|
||||
@@ -174,15 +168,6 @@ volumes:
|
||||
- /path/to/calibre-web/app.db:/auth/app.db:ro
|
||||
```
|
||||
|
||||
### Multi-User Features (Built-in & OIDC)
|
||||
|
||||
- Admin user management panel in Settings
|
||||
- Per-user download destination overrides
|
||||
- Per-user BookLore library/path overrides
|
||||
- Per-user email recipient overrides
|
||||
- Download queue scoped per user (admins see all)
|
||||
- `{User}` template variable for organizing downloads by user
|
||||
|
||||
## Health Monitoring
|
||||
|
||||
The application exposes a health endpoint at `/api/health` (no authentication required). Add a health check to your compose:
|
||||
@@ -230,16 +215,13 @@ The frontend dev server proxies to the backend on port 8084.
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Flask Backend │
|
||||
│ (REST API + WebSocket) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Authentication │
|
||||
│ (Built-in / OIDC / Proxy / CWA / None) │
|
||||
├───────────────────┬─────────────────────┬───────────────────┤
|
||||
│ Metadata Providers│ Download Queue │ Cloudflare │
|
||||
│ │ & Orchestrator │ Bypass │
|
||||
├───────────────────┼─────────────────────┼───────────────────┤
|
||||
│ • Hardcover │ • Task scheduling │ • Internal │
|
||||
│ • Open Library │ • Progress tracking │ • External │
|
||||
│ │ • Per-user scoping │ (FlareSolverr) │
|
||||
│ │ • Retry logic │ (FlareSolverr) │
|
||||
├───────────────────┴─────────────────────┴───────────────────┤
|
||||
│ Release Sources │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
|
||||
+45
-20
@@ -21,6 +21,7 @@ class WebSocketManager:
|
||||
self._on_all_disconnect_callbacks: List[Callable[[], None]] = []
|
||||
self._needs_rewarm = False # Flag to trigger warmup callbacks on next connect
|
||||
self._user_rooms: Dict[str, int] = {} # room_name -> ref count
|
||||
self._sid_rooms: Dict[str, str] = {} # sid -> room_name
|
||||
self._rooms_lock = threading.Lock()
|
||||
self._queue_status_fn: Optional[Callable] = None # Reference to queue_status()
|
||||
|
||||
@@ -107,29 +108,53 @@ class WebSocketManager:
|
||||
"""Set the queue_status function reference for per-room filtering."""
|
||||
self._queue_status_fn = fn
|
||||
|
||||
def _increment_user_room_locked(self, room: str):
|
||||
self._user_rooms[room] = self._user_rooms.get(room, 0) + 1
|
||||
|
||||
def _decrement_user_room_locked(self, room: str):
|
||||
count = self._user_rooms.get(room, 1) - 1
|
||||
if count <= 0:
|
||||
self._user_rooms.pop(room, None)
|
||||
else:
|
||||
self._user_rooms[room] = count
|
||||
|
||||
def _set_sid_room_locked(self, sid: str, room: Optional[str]):
|
||||
current_room = self._sid_rooms.get(sid)
|
||||
if current_room == room:
|
||||
return
|
||||
|
||||
if current_room is not None:
|
||||
leave_room(current_room, sid=sid)
|
||||
if current_room.startswith("user_"):
|
||||
self._decrement_user_room_locked(current_room)
|
||||
self._sid_rooms.pop(sid, None)
|
||||
|
||||
if room is not None:
|
||||
join_room(room, sid=sid)
|
||||
self._sid_rooms[sid] = room
|
||||
if room.startswith("user_"):
|
||||
self._increment_user_room_locked(room)
|
||||
|
||||
def sync_user_room(self, sid: str, is_admin: bool, db_user_id: Optional[int] = None):
|
||||
"""Ensure a SID is in exactly one room matching the current session scope."""
|
||||
room: Optional[str] = None
|
||||
if is_admin:
|
||||
room = "admins"
|
||||
elif db_user_id is not None:
|
||||
room = f"user_{db_user_id}"
|
||||
|
||||
with self._rooms_lock:
|
||||
self._set_sid_room_locked(sid, room)
|
||||
|
||||
def join_user_room(self, sid: str, is_admin: bool, db_user_id: Optional[int] = None):
|
||||
"""Join the appropriate room based on user role."""
|
||||
if is_admin or db_user_id is None:
|
||||
join_room("admins", sid=sid)
|
||||
else:
|
||||
room = f"user_{db_user_id}"
|
||||
join_room(room, sid=sid)
|
||||
with self._rooms_lock:
|
||||
self._user_rooms[room] = self._user_rooms.get(room, 0) + 1
|
||||
self.sync_user_room(sid, is_admin, db_user_id)
|
||||
|
||||
def leave_user_room(self, sid: str, is_admin: bool, db_user_id: Optional[int] = None):
|
||||
"""Leave the user's room on disconnect."""
|
||||
if is_admin or db_user_id is None:
|
||||
leave_room("admins", sid=sid)
|
||||
else:
|
||||
room = f"user_{db_user_id}"
|
||||
leave_room(room, sid=sid)
|
||||
with self._rooms_lock:
|
||||
count = self._user_rooms.get(room, 1) - 1
|
||||
if count <= 0:
|
||||
self._user_rooms.pop(room, None)
|
||||
else:
|
||||
self._user_rooms[room] = count
|
||||
def leave_user_room(self, sid: str, is_admin: bool = False, db_user_id: Optional[int] = None):
|
||||
"""Leave whichever room the SID currently belongs to."""
|
||||
del is_admin, db_user_id # Backward-compatible signature; routing is SID-based.
|
||||
with self._rooms_lock:
|
||||
self._set_sid_room_locked(sid, None)
|
||||
|
||||
def broadcast_status_update(self, status_data: Dict[str, Any]):
|
||||
"""Broadcast status update to all connected clients, filtered by user room."""
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
from typing import Any, Dict, Callable
|
||||
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from shelfmark.config.migrations import migrate_security_settings
|
||||
from shelfmark.config.security_handlers import (
|
||||
on_save_security,
|
||||
@@ -58,13 +56,7 @@ def _migrate_security_settings() -> None:
|
||||
|
||||
|
||||
def _on_save_security(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return on_save_security(
|
||||
values,
|
||||
load_security_config=lambda: load_config_file("security"),
|
||||
hash_password=generate_password_hash,
|
||||
sync_builtin_admin_user=sync_builtin_admin_user,
|
||||
logger=logger,
|
||||
)
|
||||
return on_save_security(values)
|
||||
|
||||
|
||||
def _test_oidc_connection() -> Dict[str, Any]:
|
||||
|
||||
@@ -18,44 +18,11 @@ def _has_local_password_admin() -> bool:
|
||||
|
||||
def on_save_security(
|
||||
values: dict[str, Any],
|
||||
*,
|
||||
load_security_config: Callable[[], dict[str, Any]],
|
||||
hash_password: Callable[[str], str],
|
||||
sync_builtin_admin_user: Callable[[str, str], None],
|
||||
logger: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate/process security values before persistence."""
|
||||
"""Validate security values before persistence."""
|
||||
if values.get("AUTH_METHOD") == "oidc" and not _has_local_password_admin():
|
||||
return {"error": True, "message": _OIDC_LOCKOUT_MESSAGE, "values": values}
|
||||
|
||||
password = values.pop("BUILTIN_PASSWORD", "")
|
||||
password_confirm = values.pop("BUILTIN_PASSWORD_CONFIRM", "")
|
||||
|
||||
if password:
|
||||
if not values.get("BUILTIN_USERNAME"):
|
||||
return {"error": True, "message": "Username cannot be empty", "values": values}
|
||||
if password != password_confirm:
|
||||
return {"error": True, "message": "Passwords do not match", "values": values}
|
||||
if len(password) < 4:
|
||||
return {"error": True, "message": "Password must be at least 4 characters", "values": values}
|
||||
|
||||
values["BUILTIN_PASSWORD_HASH"] = hash_password(password)
|
||||
logger.info("Password hash updated")
|
||||
elif "BUILTIN_USERNAME" in values:
|
||||
existing = load_security_config()
|
||||
if "BUILTIN_PASSWORD_HASH" in existing:
|
||||
values["BUILTIN_PASSWORD_HASH"] = existing["BUILTIN_PASSWORD_HASH"]
|
||||
|
||||
if values.get("AUTH_METHOD") == "builtin":
|
||||
try:
|
||||
sync_builtin_admin_user(
|
||||
values.get("BUILTIN_USERNAME", ""),
|
||||
values.get("BUILTIN_PASSWORD_HASH", ""),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to sync builtin admin user: {exc}")
|
||||
return {"error": True, "message": "Failed to create/update local admin user from builtin credentials", "values": values}
|
||||
|
||||
return {"error": False, "values": values}
|
||||
|
||||
|
||||
|
||||
@@ -7,15 +7,178 @@ that talks to /api/admin/users endpoints.
|
||||
|
||||
from shelfmark.core.settings_registry import (
|
||||
CheckboxField,
|
||||
CustomComponentField,
|
||||
HeadingField,
|
||||
NumberField,
|
||||
SelectField,
|
||||
TableField,
|
||||
register_on_save,
|
||||
register_settings,
|
||||
)
|
||||
from shelfmark.core.request_policy import (
|
||||
get_source_content_type_capabilities,
|
||||
parse_policy_mode,
|
||||
validate_policy_rules,
|
||||
)
|
||||
|
||||
|
||||
@register_settings("users", "Users", icon="users", order=6)
|
||||
_REQUEST_DEFAULT_MODE_OPTIONS = [
|
||||
{
|
||||
"value": "download",
|
||||
"label": "Download",
|
||||
"description": "Allow direct downloads.",
|
||||
},
|
||||
{
|
||||
"value": "request_release",
|
||||
"label": "Request Release",
|
||||
"description": "Block direct download; allow requesting a specific release.",
|
||||
},
|
||||
{
|
||||
"value": "request_book",
|
||||
"label": "Request Book",
|
||||
"description": "Block direct download; allow book-level requests only.",
|
||||
},
|
||||
{
|
||||
"value": "blocked",
|
||||
"label": "Blocked",
|
||||
"description": "Block both downloading and requesting.",
|
||||
},
|
||||
]
|
||||
|
||||
_REQUEST_MATRIX_MODE_OPTIONS = [
|
||||
option for option in _REQUEST_DEFAULT_MODE_OPTIONS if option["value"] != "request_book"
|
||||
]
|
||||
|
||||
_USERS_HEADING_DESCRIPTION_BY_AUTH_MODE = {
|
||||
"builtin": (
|
||||
"Create and manage user accounts directly. Passwords are stored locally and users sign in "
|
||||
"with their username and password."
|
||||
),
|
||||
"oidc": (
|
||||
"Users sign in through your identity provider. New accounts can be created automatically on "
|
||||
"first login when auto-provisioning is enabled, or you can pre-create users here and they\u2019ll "
|
||||
"be linked by email on first sign-in."
|
||||
),
|
||||
"proxy": (
|
||||
"Users are authenticated by your reverse proxy. Accounts are automatically created on first "
|
||||
"sign-in. If a local user with a matching username already exists, it will be linked instead."
|
||||
),
|
||||
"cwa": (
|
||||
"User accounts are synced from your Calibre-Web database. Users are matched by email, and new "
|
||||
"accounts are created here when new CWA users are found."
|
||||
),
|
||||
"none": "Authentication is disabled. Anyone can access Shelfmark without signing in.",
|
||||
"default": "Authentication is disabled. Anyone can access Shelfmark without signing in.",
|
||||
}
|
||||
|
||||
|
||||
def _get_request_source_options():
|
||||
"""Build request-policy source options from registered release sources."""
|
||||
from shelfmark.release_sources import list_available_sources
|
||||
|
||||
options = []
|
||||
for source in list_available_sources():
|
||||
options.append(
|
||||
{
|
||||
"value": source["name"],
|
||||
"label": source["display_name"],
|
||||
}
|
||||
)
|
||||
return options
|
||||
|
||||
|
||||
def _get_request_policy_rule_columns():
|
||||
source_capabilities = get_source_content_type_capabilities()
|
||||
content_type_options = []
|
||||
|
||||
for source_name, supported_types in source_capabilities.items():
|
||||
normalized_types = [t for t in ("ebook", "audiobook") if t in supported_types]
|
||||
for content_type in normalized_types:
|
||||
content_type_options.append(
|
||||
{
|
||||
"value": content_type,
|
||||
"label": "Ebook" if content_type == "ebook" else "Audiobook",
|
||||
"childOf": source_name,
|
||||
}
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"key": "source",
|
||||
"label": "Source",
|
||||
"type": "select",
|
||||
"options": _get_request_source_options(),
|
||||
"defaultValue": "",
|
||||
"placeholder": "Select source...",
|
||||
},
|
||||
{
|
||||
"key": "content_type",
|
||||
"label": "Content Type",
|
||||
"type": "select",
|
||||
"options": content_type_options,
|
||||
"defaultValue": "",
|
||||
"placeholder": "Select content type...",
|
||||
"filterByField": "source",
|
||||
},
|
||||
{
|
||||
"key": "mode",
|
||||
"label": "Mode",
|
||||
"type": "select",
|
||||
"options": _REQUEST_MATRIX_MODE_OPTIONS,
|
||||
"defaultValue": "",
|
||||
"placeholder": "Select mode...",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _on_save_users(values):
|
||||
"""Validate users/request-policy settings before persistence."""
|
||||
if "REQUEST_POLICY_DEFAULT_EBOOK" in values:
|
||||
if parse_policy_mode(values["REQUEST_POLICY_DEFAULT_EBOOK"]) is None:
|
||||
return {
|
||||
"error": True,
|
||||
"message": "REQUEST_POLICY_DEFAULT_EBOOK must be a valid policy mode",
|
||||
"values": values,
|
||||
}
|
||||
|
||||
if "REQUEST_POLICY_DEFAULT_AUDIOBOOK" in values:
|
||||
if parse_policy_mode(values["REQUEST_POLICY_DEFAULT_AUDIOBOOK"]) is None:
|
||||
return {
|
||||
"error": True,
|
||||
"message": "REQUEST_POLICY_DEFAULT_AUDIOBOOK must be a valid policy mode",
|
||||
"values": values,
|
||||
}
|
||||
|
||||
if "REQUEST_POLICY_RULES" in values:
|
||||
normalized_rules, errors = validate_policy_rules(values["REQUEST_POLICY_RULES"])
|
||||
if errors:
|
||||
return {
|
||||
"error": True,
|
||||
"message": "; ".join(errors),
|
||||
"values": values,
|
||||
}
|
||||
values["REQUEST_POLICY_RULES"] = normalized_rules
|
||||
|
||||
return {"error": False, "values": values}
|
||||
|
||||
|
||||
register_on_save("users", _on_save_users)
|
||||
|
||||
|
||||
@register_settings("users", "Users & Requests", icon="users", order=6)
|
||||
def users_settings():
|
||||
"""User management tab - rendered as a custom component on the frontend."""
|
||||
return [
|
||||
HeadingField(
|
||||
key="users_heading",
|
||||
title="Users",
|
||||
description=_USERS_HEADING_DESCRIPTION_BY_AUTH_MODE["default"],
|
||||
description_by_auth_mode=_USERS_HEADING_DESCRIPTION_BY_AUTH_MODE,
|
||||
),
|
||||
CustomComponentField(
|
||||
key="users_management",
|
||||
component="users_management",
|
||||
),
|
||||
HeadingField(
|
||||
key="users_access_heading",
|
||||
title="Options",
|
||||
@@ -31,4 +194,83 @@ def users_settings():
|
||||
default=True,
|
||||
env_supported=False,
|
||||
),
|
||||
HeadingField(
|
||||
key="requests_heading",
|
||||
title="Request Policy",
|
||||
description=(
|
||||
"Configure when users can download directly and when they must create requests."
|
||||
),
|
||||
),
|
||||
CheckboxField(
|
||||
key="REQUESTS_ENABLED",
|
||||
label="Enable Request Workflow",
|
||||
description=(
|
||||
"When disabled, request actions are hidden and only direct downloads are used."
|
||||
),
|
||||
default=False,
|
||||
user_overridable=True,
|
||||
),
|
||||
CustomComponentField(
|
||||
key="request_policy_editor",
|
||||
component="request_policy_grid",
|
||||
label="Request Policy Rules",
|
||||
description=(
|
||||
"Source/content-type rules can only restrict the content-type default ceiling."
|
||||
),
|
||||
show_when={"field": "REQUESTS_ENABLED", "value": True},
|
||||
wrap_in_field_wrapper=True,
|
||||
value_fields=[
|
||||
SelectField(
|
||||
key="REQUEST_POLICY_DEFAULT_EBOOK",
|
||||
label="Default Ebook Mode",
|
||||
description=(
|
||||
"Global ceiling for ebook actions. Source rules can only match or restrict this mode."
|
||||
),
|
||||
options=_REQUEST_DEFAULT_MODE_OPTIONS,
|
||||
default="download",
|
||||
user_overridable=True,
|
||||
),
|
||||
SelectField(
|
||||
key="REQUEST_POLICY_DEFAULT_AUDIOBOOK",
|
||||
label="Default Audiobook Mode",
|
||||
description=(
|
||||
"Global ceiling for audiobook actions. Source rules can only match or restrict this mode."
|
||||
),
|
||||
options=_REQUEST_DEFAULT_MODE_OPTIONS,
|
||||
default="download",
|
||||
user_overridable=True,
|
||||
),
|
||||
TableField(
|
||||
key="REQUEST_POLICY_RULES",
|
||||
label="Request Policy Rules",
|
||||
description=(
|
||||
"Source/content-type rules can only restrict the content-type default ceiling."
|
||||
),
|
||||
columns=_get_request_policy_rule_columns,
|
||||
default=[],
|
||||
add_label="Add Rule",
|
||||
empty_message="No request policy rules configured.",
|
||||
env_supported=False,
|
||||
user_overridable=True,
|
||||
),
|
||||
],
|
||||
),
|
||||
NumberField(
|
||||
key="MAX_PENDING_REQUESTS_PER_USER",
|
||||
label="Max Pending Requests Per User",
|
||||
description="Maximum number of pending requests a user can have at once.",
|
||||
default=20,
|
||||
min_value=1,
|
||||
max_value=1000,
|
||||
user_overridable=True,
|
||||
show_when={"field": "REQUESTS_ENABLED", "value": True},
|
||||
),
|
||||
CheckboxField(
|
||||
key="REQUESTS_ALLOW_NOTES",
|
||||
label="Allow Request Notes",
|
||||
description="Allow users to include notes when creating requests.",
|
||||
default=True,
|
||||
user_overridable=True,
|
||||
show_when={"field": "REQUESTS_ENABLED", "value": True},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -6,12 +6,14 @@ from flask import Flask, jsonify, request
|
||||
|
||||
from shelfmark.core.settings_registry import load_config_file
|
||||
from shelfmark.core.user_db import UserDB
|
||||
from shelfmark.core.request_policy import parse_policy_mode, validate_policy_rules
|
||||
|
||||
|
||||
def _get_settings_registry():
|
||||
# Ensure settings modules are loaded before reading registry metadata.
|
||||
import shelfmark.config.settings # noqa: F401
|
||||
import shelfmark.config.security # noqa: F401
|
||||
import shelfmark.config.users_settings # noqa: F401
|
||||
from shelfmark.core import settings_registry
|
||||
|
||||
return settings_registry
|
||||
@@ -39,6 +41,24 @@ def validate_user_settings(settings: dict[str, Any]) -> tuple[dict[str, Any], li
|
||||
elif key not in overridable_map:
|
||||
errors.append(f"Setting not user-overridable: {key}")
|
||||
else:
|
||||
# null means "clear the per-user override; use global default"
|
||||
if value is None:
|
||||
valid[key] = None
|
||||
continue
|
||||
|
||||
if key in {"REQUEST_POLICY_DEFAULT_EBOOK", "REQUEST_POLICY_DEFAULT_AUDIOBOOK"}:
|
||||
if parse_policy_mode(value) is None:
|
||||
errors.append(f"Invalid policy mode for {key}: {value}")
|
||||
continue
|
||||
|
||||
if key == "REQUEST_POLICY_RULES":
|
||||
normalized_rules, rule_errors = validate_policy_rules(value)
|
||||
if rule_errors:
|
||||
errors.extend(rule_errors)
|
||||
continue
|
||||
valid[key] = normalized_rules
|
||||
continue
|
||||
|
||||
valid[key] = value
|
||||
|
||||
return valid, errors
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
"""Request-policy resolution helpers.
|
||||
|
||||
This module is intentionally pure and side-effect free so it can be reused by
|
||||
routes/services and tested independently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any, Iterable, Mapping, Sequence
|
||||
|
||||
|
||||
class PolicyMode(str, Enum):
|
||||
"""Allowed request-policy modes.
|
||||
|
||||
Ordered from most to least permissive. The content-type default acts as a
|
||||
ceiling — matrix rules can only match or restrict further, never upgrade
|
||||
beyond the default.
|
||||
"""
|
||||
|
||||
DOWNLOAD = "download"
|
||||
REQUEST_RELEASE = "request_release"
|
||||
REQUEST_BOOK = "request_book"
|
||||
BLOCKED = "blocked"
|
||||
|
||||
|
||||
# Permissiveness ordering: lower index = more permissive.
|
||||
_MODE_PERMISSIVENESS: dict[PolicyMode, int] = {
|
||||
PolicyMode.DOWNLOAD: 0,
|
||||
PolicyMode.REQUEST_RELEASE: 1,
|
||||
PolicyMode.REQUEST_BOOK: 2,
|
||||
PolicyMode.BLOCKED: 3,
|
||||
}
|
||||
|
||||
# Modes allowed in REQUEST_POLICY_RULES matrix rows.
|
||||
MATRIX_ALLOWED_MODES = frozenset({PolicyMode.DOWNLOAD, PolicyMode.REQUEST_RELEASE, PolicyMode.BLOCKED})
|
||||
|
||||
|
||||
def cap_mode(mode: PolicyMode, ceiling: PolicyMode) -> PolicyMode:
|
||||
"""Cap a resolved mode so it cannot be more permissive than the ceiling."""
|
||||
if _MODE_PERMISSIVENESS[mode] < _MODE_PERMISSIVENESS[ceiling]:
|
||||
return ceiling
|
||||
return mode
|
||||
|
||||
|
||||
REQUEST_POLICY_KEYS = frozenset(
|
||||
{
|
||||
"REQUESTS_ENABLED",
|
||||
"REQUEST_POLICY_DEFAULT_EBOOK",
|
||||
"REQUEST_POLICY_DEFAULT_AUDIOBOOK",
|
||||
"REQUEST_POLICY_RULES",
|
||||
"MAX_PENDING_REQUESTS_PER_USER",
|
||||
"REQUESTS_ALLOW_NOTES",
|
||||
}
|
||||
)
|
||||
|
||||
REQUEST_POLICY_DEFAULT_FALLBACK_MODE = PolicyMode.REQUEST_BOOK
|
||||
|
||||
DEFAULT_SUPPORTED_CONTENT_TYPES = ("ebook", "audiobook")
|
||||
|
||||
|
||||
def filter_request_policy_settings(settings: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
"""Return only uppercase request-policy keys from a settings JSON object."""
|
||||
if not isinstance(settings, Mapping):
|
||||
return {}
|
||||
return {key: settings[key] for key in REQUEST_POLICY_KEYS if key in settings}
|
||||
|
||||
|
||||
def merge_request_policy_settings(
|
||||
global_settings: Mapping[str, Any] | None,
|
||||
user_settings: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Merge global settings with per-user request-policy overrides."""
|
||||
merged = filter_request_policy_settings(global_settings)
|
||||
user_filtered = filter_request_policy_settings(user_settings)
|
||||
|
||||
# Preserve global rules by default and treat user rules as per-cell overlays.
|
||||
# This allows per-user REQUEST_POLICY_RULES payloads to store only explicit
|
||||
# differences instead of replacing the full global matrix.
|
||||
global_rules = list(_iter_rules(merged.get("REQUEST_POLICY_RULES", [])))
|
||||
user_has_rules = "REQUEST_POLICY_RULES" in user_filtered
|
||||
|
||||
for key, value in user_filtered.items():
|
||||
if key == "REQUEST_POLICY_RULES":
|
||||
continue
|
||||
merged[key] = value
|
||||
|
||||
if user_has_rules:
|
||||
merged_rules: dict[tuple[str, str], tuple[str, str, PolicyMode]] = {
|
||||
(source, content_type): (source, content_type, mode)
|
||||
for source, content_type, mode in global_rules
|
||||
}
|
||||
for source, content_type, mode in _iter_rules(user_filtered.get("REQUEST_POLICY_RULES", [])):
|
||||
merged_rules[(source, content_type)] = (source, content_type, mode)
|
||||
merged["REQUEST_POLICY_RULES"] = [
|
||||
{"source": source, "content_type": content_type, "mode": mode.value}
|
||||
for source, content_type, mode in merged_rules.values()
|
||||
]
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def normalize_content_type(content_type: Any) -> str:
|
||||
"""Normalize arbitrary content type values to `ebook` or `audiobook`."""
|
||||
if not isinstance(content_type, str):
|
||||
return "ebook"
|
||||
|
||||
value = content_type.strip().lower()
|
||||
if not value:
|
||||
return "ebook"
|
||||
|
||||
if value in {"audiobook", "audiobooks", "audio", "book (audiobook)"}:
|
||||
return "audiobook"
|
||||
|
||||
return "ebook"
|
||||
|
||||
|
||||
def normalize_source(source: Any) -> str:
|
||||
"""Normalize source values for policy matching."""
|
||||
if not isinstance(source, str):
|
||||
return "*"
|
||||
|
||||
value = source.strip().lower()
|
||||
return value or "*"
|
||||
|
||||
|
||||
def parse_policy_mode(mode: Any) -> PolicyMode | None:
|
||||
"""Parse an arbitrary mode value into a PolicyMode enum member."""
|
||||
if isinstance(mode, PolicyMode):
|
||||
return mode
|
||||
if not isinstance(mode, str):
|
||||
return None
|
||||
try:
|
||||
return PolicyMode(mode.strip().lower())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_rule_content_type(content_type: Any) -> str | None:
|
||||
if not isinstance(content_type, str):
|
||||
return None
|
||||
value = content_type.strip().lower()
|
||||
if not value:
|
||||
return None
|
||||
if value in {"*", "any"}:
|
||||
return "*"
|
||||
if value in {"ebook", "book", "books", "book (fiction)"}:
|
||||
return "ebook"
|
||||
if value in {"audiobook", "audiobooks", "audio", "book (audiobook)"}:
|
||||
return "audiobook"
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_rule_source(source: Any) -> str | None:
|
||||
if not isinstance(source, str):
|
||||
return None
|
||||
value = source.strip().lower()
|
||||
if not value:
|
||||
return None
|
||||
if value in {"*", "any"}:
|
||||
return "*"
|
||||
return value
|
||||
|
||||
|
||||
def get_source_content_type_capabilities() -> dict[str, set[str]]:
|
||||
"""Return source -> supported content type map from registered sources."""
|
||||
try:
|
||||
from shelfmark.release_sources import list_available_sources
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
capabilities: dict[str, set[str]] = {}
|
||||
for source in list_available_sources():
|
||||
raw_name = source.get("name")
|
||||
name = normalize_source(raw_name)
|
||||
if not name or name == "*":
|
||||
continue
|
||||
|
||||
raw_types = source.get("supported_content_types", DEFAULT_SUPPORTED_CONTENT_TYPES)
|
||||
if isinstance(raw_types, str) or not isinstance(raw_types, Sequence):
|
||||
raw_types = DEFAULT_SUPPORTED_CONTENT_TYPES
|
||||
|
||||
normalized_types: set[str] = set()
|
||||
for content_type in raw_types:
|
||||
normalized_type = _normalize_rule_content_type(content_type)
|
||||
if normalized_type and normalized_type != "*":
|
||||
normalized_types.add(normalized_type)
|
||||
|
||||
if not normalized_types:
|
||||
normalized_types = set(DEFAULT_SUPPORTED_CONTENT_TYPES)
|
||||
capabilities[name] = normalized_types
|
||||
return capabilities
|
||||
|
||||
|
||||
def validate_policy_rules(
|
||||
rules: Any,
|
||||
source_capabilities: Mapping[str, set[str]] | None = None,
|
||||
) -> tuple[list[dict[str, str]], list[str]]:
|
||||
"""Validate and normalize policy rule rows.
|
||||
|
||||
Validation covers:
|
||||
- row shape and required keys
|
||||
- valid mode/content_type values
|
||||
- known source names
|
||||
- source/content-type compatibility from source declarations
|
||||
"""
|
||||
capabilities = source_capabilities if source_capabilities is not None else get_source_content_type_capabilities()
|
||||
normalized_capabilities = {
|
||||
normalize_source(source): {normalize_content_type(content_type) for content_type in content_types}
|
||||
for source, content_types in capabilities.items()
|
||||
}
|
||||
|
||||
normalized_rules: list[dict[str, str]] = []
|
||||
errors: list[str] = []
|
||||
|
||||
if rules is None:
|
||||
return normalized_rules, errors
|
||||
if not isinstance(rules, list):
|
||||
return normalized_rules, ["REQUEST_POLICY_RULES must be a list"]
|
||||
|
||||
for index, rule in enumerate(rules):
|
||||
row_label = f"Rule {index + 1}"
|
||||
if not isinstance(rule, Mapping):
|
||||
errors.append(f"{row_label}: must be an object")
|
||||
continue
|
||||
|
||||
source = _normalize_rule_source(rule.get("source"))
|
||||
raw_content_type = rule.get("content_type")
|
||||
content_type = _normalize_rule_content_type(rule.get("content_type"))
|
||||
raw_mode = rule.get("mode")
|
||||
mode = parse_policy_mode(rule.get("mode"))
|
||||
|
||||
if source is None:
|
||||
errors.append(f"{row_label}: source is required")
|
||||
continue
|
||||
if (
|
||||
raw_content_type is None
|
||||
or (isinstance(raw_content_type, str) and not raw_content_type.strip())
|
||||
):
|
||||
errors.append(f"{row_label}: content_type is required")
|
||||
continue
|
||||
if content_type is None:
|
||||
errors.append(f"{row_label}: invalid content_type '{rule.get('content_type')}'")
|
||||
continue
|
||||
if (
|
||||
raw_mode is None
|
||||
or (isinstance(raw_mode, str) and not raw_mode.strip())
|
||||
):
|
||||
errors.append(f"{row_label}: mode is required")
|
||||
continue
|
||||
if mode is None:
|
||||
errors.append(f"{row_label}: invalid mode '{rule.get('mode')}'")
|
||||
continue
|
||||
if mode not in MATRIX_ALLOWED_MODES:
|
||||
errors.append(f"{row_label}: mode '{mode.value}' is not allowed in matrix rules (use content-type defaults instead)")
|
||||
continue
|
||||
|
||||
if source != "*" and source not in normalized_capabilities:
|
||||
errors.append(f"{row_label}: unknown source '{source}'")
|
||||
continue
|
||||
|
||||
if (
|
||||
source != "*"
|
||||
and content_type != "*"
|
||||
and source in normalized_capabilities
|
||||
and content_type not in normalized_capabilities[source]
|
||||
):
|
||||
errors.append(
|
||||
f"{row_label}: source '{source}' does not support content_type '{content_type}'"
|
||||
)
|
||||
continue
|
||||
|
||||
normalized_rules.append(
|
||||
{
|
||||
"source": source,
|
||||
"content_type": content_type,
|
||||
"mode": mode.value,
|
||||
}
|
||||
)
|
||||
|
||||
return normalized_rules, errors
|
||||
|
||||
|
||||
def _iter_rules(rules: Any) -> Iterable[tuple[str, str, PolicyMode]]:
|
||||
if not isinstance(rules, list):
|
||||
return []
|
||||
|
||||
normalized: list[tuple[str, str, PolicyMode]] = []
|
||||
for rule in rules:
|
||||
if not isinstance(rule, Mapping):
|
||||
continue
|
||||
source = _normalize_rule_source(rule.get("source"))
|
||||
content_type = _normalize_rule_content_type(rule.get("content_type"))
|
||||
mode = parse_policy_mode(rule.get("mode"))
|
||||
if (
|
||||
source is None
|
||||
or content_type is None
|
||||
or mode is None
|
||||
or mode not in MATRIX_ALLOWED_MODES
|
||||
):
|
||||
continue
|
||||
normalized.append((source, content_type, mode))
|
||||
return normalized
|
||||
|
||||
|
||||
def resolve_policy_mode(
|
||||
*,
|
||||
source: Any,
|
||||
content_type: Any,
|
||||
global_settings: Mapping[str, Any] | None,
|
||||
user_settings: Mapping[str, Any] | None = None,
|
||||
) -> PolicyMode:
|
||||
"""Resolve an effective policy mode for a request context.
|
||||
|
||||
Resolution:
|
||||
1. Resolve the content-type default (ceiling).
|
||||
2. Match rules in specificity order.
|
||||
3. Cap the matched rule at the ceiling.
|
||||
4. If no rule matches, return the ceiling.
|
||||
|
||||
The content-type default acts as a ceiling — matrix rules can only
|
||||
match or restrict further, never upgrade beyond the default.
|
||||
"""
|
||||
|
||||
effective = merge_request_policy_settings(global_settings, user_settings)
|
||||
normalized_source = normalize_source(source)
|
||||
normalized_content_type = normalize_content_type(content_type)
|
||||
|
||||
# Resolve the content-type default (ceiling)
|
||||
default_key = (
|
||||
"REQUEST_POLICY_DEFAULT_AUDIOBOOK"
|
||||
if normalized_content_type == "audiobook"
|
||||
else "REQUEST_POLICY_DEFAULT_EBOOK"
|
||||
)
|
||||
default_mode = parse_policy_mode(effective.get(default_key))
|
||||
ceiling = default_mode if default_mode is not None else REQUEST_POLICY_DEFAULT_FALLBACK_MODE
|
||||
|
||||
# Match rules in specificity order
|
||||
rules = tuple(_iter_rules(effective.get("REQUEST_POLICY_RULES", [])))
|
||||
candidates = (
|
||||
(normalized_source, normalized_content_type),
|
||||
(normalized_source, "*"),
|
||||
("*", normalized_content_type),
|
||||
("*", "*"),
|
||||
)
|
||||
for candidate_source, candidate_content_type in candidates:
|
||||
for rule_source, rule_content_type, rule_mode in rules:
|
||||
if rule_source == candidate_source and rule_content_type == candidate_content_type:
|
||||
return cap_mode(rule_mode, ceiling)
|
||||
|
||||
return ceiling
|
||||
@@ -0,0 +1,578 @@
|
||||
"""Request API routes and policy snapshot endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
from flask import Flask, jsonify, request, session
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.request_policy import (
|
||||
PolicyMode,
|
||||
REQUEST_POLICY_DEFAULT_FALLBACK_MODE,
|
||||
get_source_content_type_capabilities,
|
||||
merge_request_policy_settings,
|
||||
normalize_content_type,
|
||||
normalize_source,
|
||||
parse_policy_mode,
|
||||
resolve_policy_mode,
|
||||
)
|
||||
from shelfmark.core.requests_service import (
|
||||
RequestServiceError,
|
||||
cancel_request,
|
||||
create_request,
|
||||
fulfil_request,
|
||||
reject_request,
|
||||
)
|
||||
from shelfmark.core.settings_registry import load_config_file
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def _load_users_request_policy_settings() -> dict[str, Any]:
|
||||
"""Load global request-policy settings from users config."""
|
||||
return load_config_file("users")
|
||||
|
||||
|
||||
def _as_bool(value: Any, default: bool = False) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "no", "off", ""}:
|
||||
return False
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _as_int(value: Any, default: int) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return parsed
|
||||
|
||||
|
||||
def _error_response(
|
||||
message: str,
|
||||
status_code: int,
|
||||
*,
|
||||
code: str | None = None,
|
||||
required_mode: str | None = None,
|
||||
):
|
||||
payload: dict[str, Any] = {"error": message}
|
||||
if code is not None:
|
||||
payload["code"] = code
|
||||
if required_mode is not None:
|
||||
payload["required_mode"] = required_mode
|
||||
return jsonify(payload), status_code
|
||||
|
||||
|
||||
def _require_request_endpoints_available(resolve_auth_mode: Callable[[], str]):
|
||||
auth_mode = resolve_auth_mode()
|
||||
if auth_mode == "none":
|
||||
return _error_response(
|
||||
"Request workflow is unavailable in no-auth mode",
|
||||
403,
|
||||
code="requests_unavailable",
|
||||
)
|
||||
if "user_id" not in session:
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
return None
|
||||
|
||||
|
||||
def _require_db_user_id() -> tuple[int | None, Any | None]:
|
||||
raw_user_id = session.get("db_user_id")
|
||||
if raw_user_id is None:
|
||||
return None, _error_response(
|
||||
"User identity is unavailable for request workflow",
|
||||
403,
|
||||
code="user_identity_unavailable",
|
||||
)
|
||||
try:
|
||||
return int(raw_user_id), None
|
||||
except (TypeError, ValueError):
|
||||
return None, _error_response(
|
||||
"User identity is unavailable for request workflow",
|
||||
403,
|
||||
code="user_identity_unavailable",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_effective_policy(
|
||||
user_db: UserDB,
|
||||
*,
|
||||
db_user_id: int | None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], bool]:
|
||||
global_settings = _load_users_request_policy_settings()
|
||||
user_settings = user_db.get_user_settings(db_user_id) if db_user_id is not None else {}
|
||||
effective = merge_request_policy_settings(global_settings, user_settings)
|
||||
requests_enabled = _as_bool(effective.get("REQUESTS_ENABLED"), False)
|
||||
return global_settings, user_settings, effective, requests_enabled
|
||||
|
||||
|
||||
def _emit_request_event(
|
||||
ws_manager: Any,
|
||||
*,
|
||||
event_name: str,
|
||||
payload: dict[str, Any],
|
||||
room: str,
|
||||
) -> None:
|
||||
if ws_manager is None:
|
||||
return
|
||||
try:
|
||||
socketio = getattr(ws_manager, "socketio", None)
|
||||
is_enabled = getattr(ws_manager, "is_enabled", None)
|
||||
if socketio is None or not callable(is_enabled) or not is_enabled():
|
||||
return
|
||||
socketio.emit(event_name, payload, to=room)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to emit WebSocket event '{event_name}' to room '{room}': {exc}")
|
||||
|
||||
|
||||
def register_request_routes(
|
||||
app: Flask,
|
||||
user_db: UserDB,
|
||||
*,
|
||||
resolve_auth_mode: Callable[[], str],
|
||||
queue_release: Callable[..., tuple[bool, str | None]],
|
||||
ws_manager: Any | None = None,
|
||||
) -> None:
|
||||
"""Register request policy and request lifecycle routes."""
|
||||
|
||||
@app.route("/api/request-policy", methods=["GET"])
|
||||
def api_request_policy():
|
||||
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
|
||||
is_admin = bool(session.get("is_admin", False))
|
||||
db_user_id: int | None = None
|
||||
if not is_admin:
|
||||
db_user_id, db_gate = _require_db_user_id()
|
||||
if db_gate is not None:
|
||||
return db_gate
|
||||
else:
|
||||
raw_id = session.get("db_user_id")
|
||||
if raw_id is not None:
|
||||
try:
|
||||
db_user_id = int(raw_id)
|
||||
except (TypeError, ValueError):
|
||||
db_user_id = None
|
||||
|
||||
global_settings, user_settings, effective, requests_enabled = _resolve_effective_policy(
|
||||
user_db,
|
||||
db_user_id=db_user_id,
|
||||
)
|
||||
|
||||
default_ebook_mode = parse_policy_mode(effective.get("REQUEST_POLICY_DEFAULT_EBOOK"))
|
||||
default_audio_mode = parse_policy_mode(effective.get("REQUEST_POLICY_DEFAULT_AUDIOBOOK"))
|
||||
|
||||
source_capabilities = get_source_content_type_capabilities()
|
||||
source_modes = []
|
||||
for source_name in sorted(source_capabilities):
|
||||
supported_types = sorted(
|
||||
source_capabilities[source_name],
|
||||
key=lambda ct: (ct != "ebook", ct),
|
||||
)
|
||||
modes = {
|
||||
content_type: resolve_policy_mode(
|
||||
source=source_name,
|
||||
content_type=content_type,
|
||||
global_settings=global_settings,
|
||||
user_settings=user_settings,
|
||||
).value
|
||||
for content_type in supported_types
|
||||
}
|
||||
source_modes.append(
|
||||
{
|
||||
"source": source_name,
|
||||
"supported_content_types": supported_types,
|
||||
"modes": modes,
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"request-policy snapshot user=%s db_user_id=%s is_admin=%s requests_enabled=%s defaults=%s",
|
||||
session.get("user_id"),
|
||||
db_user_id,
|
||||
is_admin,
|
||||
requests_enabled,
|
||||
{
|
||||
"ebook": (
|
||||
default_ebook_mode.value
|
||||
if default_ebook_mode is not None
|
||||
else REQUEST_POLICY_DEFAULT_FALLBACK_MODE.value
|
||||
),
|
||||
"audiobook": (
|
||||
default_audio_mode.value
|
||||
if default_audio_mode is not None
|
||||
else REQUEST_POLICY_DEFAULT_FALLBACK_MODE.value
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"requests_enabled": requests_enabled,
|
||||
"is_admin": is_admin,
|
||||
"allow_notes": _as_bool(effective.get("REQUESTS_ALLOW_NOTES"), default=True),
|
||||
"defaults": {
|
||||
"ebook": (
|
||||
default_ebook_mode.value
|
||||
if default_ebook_mode is not None
|
||||
else REQUEST_POLICY_DEFAULT_FALLBACK_MODE.value
|
||||
),
|
||||
"audiobook": (
|
||||
default_audio_mode.value
|
||||
if default_audio_mode is not None
|
||||
else REQUEST_POLICY_DEFAULT_FALLBACK_MODE.value
|
||||
),
|
||||
},
|
||||
"rules": effective.get("REQUEST_POLICY_RULES", []),
|
||||
"source_modes": source_modes,
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/requests", methods=["POST"])
|
||||
def api_create_request():
|
||||
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
|
||||
db_user_id, db_gate = _require_db_user_id()
|
||||
if db_gate is not None or db_user_id is None:
|
||||
return db_gate
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"error": "No data provided"}), 400
|
||||
|
||||
context = data.get("context") or {}
|
||||
if not isinstance(context, dict):
|
||||
return jsonify({"error": "context must be an object"}), 400
|
||||
|
||||
source = normalize_source(context.get("source"))
|
||||
release_data = data.get("release_data")
|
||||
request_level = context.get("request_level")
|
||||
if request_level is None:
|
||||
request_level = "book" if release_data is None else "release"
|
||||
|
||||
book_data = data.get("book_data")
|
||||
if not isinstance(book_data, dict):
|
||||
return jsonify({"error": "book_data must be an object"}), 400
|
||||
|
||||
content_type = normalize_content_type(
|
||||
context.get("content_type")
|
||||
or data.get("content_type")
|
||||
or book_data.get("content_type")
|
||||
)
|
||||
|
||||
global_settings, user_settings, effective, requests_enabled = _resolve_effective_policy(
|
||||
user_db,
|
||||
db_user_id=db_user_id,
|
||||
)
|
||||
if not requests_enabled:
|
||||
return _error_response(
|
||||
"Request workflow is disabled by policy",
|
||||
403,
|
||||
code="requests_unavailable",
|
||||
)
|
||||
|
||||
max_pending = _as_int(
|
||||
effective.get("MAX_PENDING_REQUESTS_PER_USER"),
|
||||
default=20,
|
||||
)
|
||||
if max_pending < 1:
|
||||
max_pending = 1
|
||||
if max_pending > 1000:
|
||||
max_pending = 1000
|
||||
allow_notes = _as_bool(effective.get("REQUESTS_ALLOW_NOTES"), default=True)
|
||||
note_value = data.get("note") if allow_notes else None
|
||||
|
||||
resolved_mode = resolve_policy_mode(
|
||||
source=source,
|
||||
content_type=content_type,
|
||||
global_settings=global_settings,
|
||||
user_settings=user_settings,
|
||||
)
|
||||
logger.debug(
|
||||
"request create policy user=%s db_user_id=%s source=%s content_type=%s request_level=%s resolved_mode=%s",
|
||||
session.get("user_id"),
|
||||
db_user_id,
|
||||
source,
|
||||
content_type,
|
||||
request_level,
|
||||
resolved_mode.value,
|
||||
)
|
||||
|
||||
if resolved_mode == PolicyMode.BLOCKED:
|
||||
return _error_response(
|
||||
"Requesting is blocked by policy",
|
||||
403,
|
||||
code="policy_blocked",
|
||||
required_mode=PolicyMode.BLOCKED.value,
|
||||
)
|
||||
|
||||
if resolved_mode == PolicyMode.REQUEST_BOOK:
|
||||
requested_level = str(request_level).strip().lower() if isinstance(request_level, str) else ""
|
||||
if requested_level != "book":
|
||||
return _error_response(
|
||||
"Policy requires book-level requests",
|
||||
403,
|
||||
code="policy_requires_request",
|
||||
required_mode=PolicyMode.REQUEST_BOOK.value,
|
||||
)
|
||||
|
||||
try:
|
||||
created = create_request(
|
||||
user_db,
|
||||
user_id=db_user_id,
|
||||
source_hint=source,
|
||||
content_type=content_type,
|
||||
request_level=request_level,
|
||||
policy_mode=resolved_mode.value,
|
||||
book_data=book_data,
|
||||
release_data=release_data,
|
||||
note=note_value,
|
||||
max_pending_per_user=max_pending,
|
||||
)
|
||||
except RequestServiceError as exc:
|
||||
return _error_response(str(exc), exc.status_code, code=exc.code)
|
||||
|
||||
event_payload = {
|
||||
"request_id": created["id"],
|
||||
"status": created["status"],
|
||||
"title": (created.get("book_data") or {}).get("title") or "Unknown title",
|
||||
}
|
||||
_emit_request_event(
|
||||
ws_manager,
|
||||
event_name="new_request",
|
||||
payload=event_payload,
|
||||
room="admins",
|
||||
)
|
||||
_emit_request_event(
|
||||
ws_manager,
|
||||
event_name="request_update",
|
||||
payload=event_payload,
|
||||
room=f"user_{db_user_id}",
|
||||
)
|
||||
|
||||
return jsonify(created), 201
|
||||
|
||||
@app.route("/api/requests", methods=["GET"])
|
||||
def api_list_requests():
|
||||
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
|
||||
db_user_id, db_gate = _require_db_user_id()
|
||||
if db_gate is not None or db_user_id is None:
|
||||
return db_gate
|
||||
|
||||
status = request.args.get("status")
|
||||
limit = request.args.get("limit", type=int)
|
||||
offset = request.args.get("offset", type=int, default=0) or 0
|
||||
|
||||
try:
|
||||
rows = user_db.list_requests(
|
||||
user_id=db_user_id,
|
||||
status=status,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
return jsonify(rows)
|
||||
|
||||
@app.route("/api/requests/<int:request_id>", methods=["DELETE"])
|
||||
def api_cancel_request(request_id: int):
|
||||
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
|
||||
db_user_id, db_gate = _require_db_user_id()
|
||||
if db_gate is not None or db_user_id is None:
|
||||
return db_gate
|
||||
|
||||
try:
|
||||
updated = cancel_request(
|
||||
user_db,
|
||||
request_id=request_id,
|
||||
actor_user_id=db_user_id,
|
||||
)
|
||||
except RequestServiceError as exc:
|
||||
return _error_response(str(exc), exc.status_code, code=exc.code)
|
||||
|
||||
event_payload = {
|
||||
"request_id": updated["id"],
|
||||
"status": updated["status"],
|
||||
"title": (updated.get("book_data") or {}).get("title") or "Unknown title",
|
||||
}
|
||||
_emit_request_event(
|
||||
ws_manager,
|
||||
event_name="request_update",
|
||||
payload=event_payload,
|
||||
room=f"user_{db_user_id}",
|
||||
)
|
||||
_emit_request_event(
|
||||
ws_manager,
|
||||
event_name="request_update",
|
||||
payload=event_payload,
|
||||
room="admins",
|
||||
)
|
||||
|
||||
return jsonify(updated)
|
||||
|
||||
@app.route("/api/admin/requests", methods=["GET"])
|
||||
def api_admin_list_requests():
|
||||
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
if not session.get("is_admin", False):
|
||||
return jsonify({"error": "Admin access required"}), 403
|
||||
|
||||
status = request.args.get("status")
|
||||
limit = request.args.get("limit", type=int)
|
||||
offset = request.args.get("offset", type=int, default=0) or 0
|
||||
|
||||
try:
|
||||
rows = user_db.list_requests(status=status, limit=limit, offset=offset)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
user_cache: dict[int, str] = {}
|
||||
for row in rows:
|
||||
requester_id = row["user_id"]
|
||||
if requester_id not in user_cache:
|
||||
requester = user_db.get_user(user_id=requester_id)
|
||||
user_cache[requester_id] = requester.get("username", "") if requester else ""
|
||||
row["username"] = user_cache[requester_id]
|
||||
|
||||
return jsonify(rows)
|
||||
|
||||
@app.route("/api/admin/requests/count", methods=["GET"])
|
||||
def api_admin_request_counts():
|
||||
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
if not session.get("is_admin", False):
|
||||
return jsonify({"error": "Admin access required"}), 403
|
||||
|
||||
by_status = {
|
||||
status: len(user_db.list_requests(status=status))
|
||||
for status in ("pending", "fulfilled", "rejected", "cancelled")
|
||||
}
|
||||
return jsonify(
|
||||
{
|
||||
"pending": by_status["pending"],
|
||||
"total": sum(by_status.values()),
|
||||
"by_status": by_status,
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/admin/requests/<int:request_id>/fulfil", methods=["POST"])
|
||||
def api_admin_fulfil_request(request_id: int):
|
||||
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
if not session.get("is_admin", False):
|
||||
return jsonify({"error": "Admin access required"}), 403
|
||||
|
||||
raw_admin_id = session.get("db_user_id")
|
||||
if raw_admin_id is None:
|
||||
return jsonify({"error": "Admin user identity unavailable"}), 403
|
||||
try:
|
||||
admin_user_id = int(raw_admin_id)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "Admin user identity unavailable"}), 403
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"error": "Invalid payload"}), 400
|
||||
|
||||
try:
|
||||
updated = fulfil_request(
|
||||
user_db,
|
||||
request_id=request_id,
|
||||
admin_user_id=admin_user_id,
|
||||
queue_release=queue_release,
|
||||
release_data=data.get("release_data"),
|
||||
admin_note=data.get("admin_note"),
|
||||
)
|
||||
except RequestServiceError as exc:
|
||||
return _error_response(str(exc), exc.status_code, code=exc.code)
|
||||
|
||||
event_payload = {
|
||||
"request_id": updated["id"],
|
||||
"status": updated["status"],
|
||||
"title": (updated.get("book_data") or {}).get("title") or "Unknown title",
|
||||
}
|
||||
_emit_request_event(
|
||||
ws_manager,
|
||||
event_name="request_update",
|
||||
payload=event_payload,
|
||||
room=f"user_{updated['user_id']}",
|
||||
)
|
||||
_emit_request_event(
|
||||
ws_manager,
|
||||
event_name="request_update",
|
||||
payload=event_payload,
|
||||
room="admins",
|
||||
)
|
||||
|
||||
return jsonify(updated)
|
||||
|
||||
@app.route("/api/admin/requests/<int:request_id>/reject", methods=["POST"])
|
||||
def api_admin_reject_request(request_id: int):
|
||||
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
if not session.get("is_admin", False):
|
||||
return jsonify({"error": "Admin access required"}), 403
|
||||
|
||||
raw_admin_id = session.get("db_user_id")
|
||||
if raw_admin_id is None:
|
||||
return jsonify({"error": "Admin user identity unavailable"}), 403
|
||||
try:
|
||||
admin_user_id = int(raw_admin_id)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "Admin user identity unavailable"}), 403
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"error": "Invalid payload"}), 400
|
||||
|
||||
try:
|
||||
updated = reject_request(
|
||||
user_db,
|
||||
request_id=request_id,
|
||||
admin_user_id=admin_user_id,
|
||||
admin_note=data.get("admin_note"),
|
||||
)
|
||||
except RequestServiceError as exc:
|
||||
return _error_response(str(exc), exc.status_code, code=exc.code)
|
||||
|
||||
event_payload = {
|
||||
"request_id": updated["id"],
|
||||
"status": updated["status"],
|
||||
"title": (updated.get("book_data") or {}).get("title") or "Unknown title",
|
||||
}
|
||||
_emit_request_event(
|
||||
ws_manager,
|
||||
event_name="request_update",
|
||||
payload=event_payload,
|
||||
room=f"user_{updated['user_id']}",
|
||||
)
|
||||
_emit_request_event(
|
||||
ws_manager,
|
||||
event_name="request_update",
|
||||
payload=event_payload,
|
||||
room="admins",
|
||||
)
|
||||
|
||||
return jsonify(updated)
|
||||
@@ -0,0 +1,391 @@
|
||||
"""Request lifecycle helpers and service-level validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from typing import Any, Callable, TYPE_CHECKING
|
||||
|
||||
from shelfmark.core.request_policy import normalize_content_type, parse_policy_mode
|
||||
|
||||
|
||||
VALID_REQUEST_STATUSES = frozenset({"pending", "fulfilled", "rejected", "cancelled"})
|
||||
TERMINAL_REQUEST_STATUSES = frozenset({"fulfilled", "rejected", "cancelled"})
|
||||
VALID_REQUEST_LEVELS = frozenset({"book", "release"})
|
||||
MAX_REQUEST_NOTE_LENGTH = 1000
|
||||
MAX_REQUEST_JSON_BLOB_BYTES = 10 * 1024
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
|
||||
class RequestServiceError(ValueError):
|
||||
"""Structured error raised by request lifecycle service methods."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
status_code: int = 400,
|
||||
code: str | None = None,
|
||||
):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.code = code
|
||||
|
||||
|
||||
def normalize_request_status(status: Any) -> str:
|
||||
"""Validate and normalize request status values."""
|
||||
if not isinstance(status, str):
|
||||
raise ValueError(f"Invalid request status: {status}")
|
||||
normalized = status.strip().lower()
|
||||
if normalized not in VALID_REQUEST_STATUSES:
|
||||
raise ValueError(f"Invalid request status: {status}")
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_policy_mode(mode: Any) -> str:
|
||||
"""Validate and normalize policy mode values."""
|
||||
parsed = parse_policy_mode(mode)
|
||||
if parsed is None:
|
||||
raise ValueError(f"Invalid policy_mode: {mode}")
|
||||
return parsed.value
|
||||
|
||||
|
||||
def normalize_request_level(request_level: Any) -> str:
|
||||
"""Validate and normalize request level values."""
|
||||
if not isinstance(request_level, str):
|
||||
raise ValueError(f"Invalid request_level: {request_level}")
|
||||
normalized = request_level.strip().lower()
|
||||
if normalized not in VALID_REQUEST_LEVELS:
|
||||
raise ValueError(f"Invalid request_level: {request_level}")
|
||||
return normalized
|
||||
|
||||
|
||||
def validate_request_level_payload(request_level: Any, release_data: Any) -> str:
|
||||
"""Validate request_level and release_data shape coupling."""
|
||||
normalized_level = normalize_request_level(request_level)
|
||||
if normalized_level == "release" and release_data is None:
|
||||
raise ValueError("request_level=release requires non-null release_data")
|
||||
if normalized_level == "book" and release_data is not None:
|
||||
raise ValueError("request_level=book requires null release_data")
|
||||
return normalized_level
|
||||
|
||||
|
||||
def validate_status_transition(current_status: Any, new_status: Any) -> tuple[str, str]:
|
||||
"""Validate request status transitions and terminal immutability."""
|
||||
current = normalize_request_status(current_status)
|
||||
new = normalize_request_status(new_status)
|
||||
if current in TERMINAL_REQUEST_STATUSES and new != current:
|
||||
raise ValueError("Terminal request statuses are immutable")
|
||||
return current, new
|
||||
|
||||
|
||||
def _normalize_match_text(value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
return value.strip().lower()
|
||||
|
||||
|
||||
def normalize_note(note: Any) -> str | None:
|
||||
"""Validate request notes and normalize empty strings to None."""
|
||||
if note is None:
|
||||
return None
|
||||
if not isinstance(note, str):
|
||||
raise RequestServiceError("note must be a string", status_code=400)
|
||||
normalized = note.strip()
|
||||
if len(normalized) > MAX_REQUEST_NOTE_LENGTH:
|
||||
raise RequestServiceError(
|
||||
f"note must be <= {MAX_REQUEST_NOTE_LENGTH} characters",
|
||||
status_code=400,
|
||||
)
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _validate_book_data(book_data: Any) -> dict[str, Any]:
|
||||
if not isinstance(book_data, dict):
|
||||
raise RequestServiceError("book_data must be an object", status_code=400)
|
||||
|
||||
required_fields = ("title", "author", "provider", "provider_id")
|
||||
missing = [field for field in required_fields if not _normalize_match_text(book_data.get(field))]
|
||||
if missing:
|
||||
raise RequestServiceError(
|
||||
f"book_data missing required field(s): {', '.join(missing)}",
|
||||
status_code=400,
|
||||
)
|
||||
return dict(book_data)
|
||||
|
||||
|
||||
def _validate_json_blob_size(field: str, payload: Any) -> None:
|
||||
if payload is None:
|
||||
return
|
||||
|
||||
try:
|
||||
serialized = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise RequestServiceError(f"{field} must be JSON-serializable", status_code=400) from exc
|
||||
|
||||
payload_size = len(serialized.encode("utf-8"))
|
||||
if payload_size > MAX_REQUEST_JSON_BLOB_BYTES:
|
||||
raise RequestServiceError(
|
||||
f"{field} must be <= {MAX_REQUEST_JSON_BLOB_BYTES} bytes",
|
||||
status_code=400,
|
||||
code="request_payload_too_large",
|
||||
)
|
||||
|
||||
|
||||
def _find_duplicate_pending_request(
|
||||
user_db: "UserDB",
|
||||
*,
|
||||
user_id: int,
|
||||
title: str,
|
||||
author: str,
|
||||
content_type: str,
|
||||
) -> dict[str, Any] | None:
|
||||
pending_rows = user_db.list_requests(user_id=user_id, status="pending")
|
||||
for row in pending_rows:
|
||||
row_book_data = row.get("book_data") or {}
|
||||
if not isinstance(row_book_data, dict):
|
||||
continue
|
||||
|
||||
row_title = _normalize_match_text(row_book_data.get("title"))
|
||||
row_author = _normalize_match_text(row_book_data.get("author"))
|
||||
row_content_type = normalize_content_type(
|
||||
row.get("content_type") or row_book_data.get("content_type")
|
||||
)
|
||||
if row_title == title and row_author == author and row_content_type == content_type:
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _now_timestamp() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def create_request(
|
||||
user_db: "UserDB",
|
||||
*,
|
||||
user_id: int,
|
||||
source_hint: str | None,
|
||||
content_type: Any,
|
||||
request_level: Any,
|
||||
policy_mode: Any,
|
||||
book_data: Any,
|
||||
release_data: Any = None,
|
||||
note: Any = None,
|
||||
max_pending_per_user: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a pending request after service-level validation."""
|
||||
validated_book_data = _validate_book_data(book_data)
|
||||
normalized_note = normalize_note(note)
|
||||
normalized_content_type = normalize_content_type(
|
||||
content_type or validated_book_data.get("content_type")
|
||||
)
|
||||
validated_book_data["content_type"] = normalized_content_type
|
||||
|
||||
try:
|
||||
normalized_request_level = validate_request_level_payload(request_level, release_data)
|
||||
normalized_policy_mode = normalize_policy_mode(policy_mode)
|
||||
except ValueError as exc:
|
||||
raise RequestServiceError(str(exc), status_code=400) from exc
|
||||
|
||||
_validate_json_blob_size("book_data", validated_book_data)
|
||||
_validate_json_blob_size("release_data", release_data)
|
||||
|
||||
if max_pending_per_user is not None:
|
||||
pending_count = user_db.count_user_pending_requests(user_id)
|
||||
if pending_count >= max_pending_per_user:
|
||||
raise RequestServiceError(
|
||||
"Maximum pending requests reached for this user",
|
||||
status_code=409,
|
||||
code="max_pending_reached",
|
||||
)
|
||||
|
||||
duplicate = _find_duplicate_pending_request(
|
||||
user_db,
|
||||
user_id=user_id,
|
||||
title=_normalize_match_text(validated_book_data.get("title")),
|
||||
author=_normalize_match_text(validated_book_data.get("author")),
|
||||
content_type=normalized_content_type,
|
||||
)
|
||||
if duplicate is not None:
|
||||
raise RequestServiceError(
|
||||
"Duplicate pending request exists for this title/author/content_type",
|
||||
status_code=409,
|
||||
code="duplicate_pending_request",
|
||||
)
|
||||
|
||||
try:
|
||||
return user_db.create_request(
|
||||
user_id=user_id,
|
||||
source_hint=source_hint,
|
||||
content_type=normalized_content_type,
|
||||
request_level=normalized_request_level,
|
||||
policy_mode=normalized_policy_mode,
|
||||
book_data=validated_book_data,
|
||||
release_data=release_data,
|
||||
note=normalized_note,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise RequestServiceError(str(exc), status_code=400) from exc
|
||||
|
||||
|
||||
def ensure_request_access(
|
||||
user_db: "UserDB",
|
||||
*,
|
||||
request_id: int,
|
||||
actor_user_id: int | None,
|
||||
is_admin: bool,
|
||||
) -> dict[str, Any]:
|
||||
"""Get request by ID and enforce ownership for non-admin actors."""
|
||||
request_row = user_db.get_request(request_id)
|
||||
if request_row is None:
|
||||
raise RequestServiceError("Request not found", status_code=404)
|
||||
|
||||
if not is_admin:
|
||||
if actor_user_id is None or request_row["user_id"] != actor_user_id:
|
||||
raise RequestServiceError("Forbidden", status_code=403)
|
||||
|
||||
return request_row
|
||||
|
||||
|
||||
def cancel_request(
|
||||
user_db: "UserDB",
|
||||
*,
|
||||
request_id: int,
|
||||
actor_user_id: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Cancel a pending request owned by the actor."""
|
||||
request_row = ensure_request_access(
|
||||
user_db,
|
||||
request_id=request_id,
|
||||
actor_user_id=actor_user_id,
|
||||
is_admin=False,
|
||||
)
|
||||
if request_row["status"] != "pending":
|
||||
raise RequestServiceError(
|
||||
"Request is already in a terminal state",
|
||||
status_code=409,
|
||||
code="stale_transition",
|
||||
)
|
||||
|
||||
try:
|
||||
return user_db.update_request(request_id, status="cancelled")
|
||||
except ValueError as exc:
|
||||
raise RequestServiceError(str(exc), status_code=409, code="stale_transition") from exc
|
||||
|
||||
|
||||
def reject_request(
|
||||
user_db: "UserDB",
|
||||
*,
|
||||
request_id: int,
|
||||
admin_user_id: int,
|
||||
admin_note: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Reject a pending request as admin."""
|
||||
request_row = ensure_request_access(
|
||||
user_db,
|
||||
request_id=request_id,
|
||||
actor_user_id=admin_user_id,
|
||||
is_admin=True,
|
||||
)
|
||||
if request_row["status"] != "pending":
|
||||
raise RequestServiceError(
|
||||
"Request is already in a terminal state",
|
||||
status_code=409,
|
||||
code="stale_transition",
|
||||
)
|
||||
|
||||
normalized_admin_note = None
|
||||
if admin_note is not None:
|
||||
if not isinstance(admin_note, str):
|
||||
raise RequestServiceError("admin_note must be a string", status_code=400)
|
||||
normalized_admin_note = admin_note.strip() or None
|
||||
|
||||
try:
|
||||
return user_db.update_request(
|
||||
request_id,
|
||||
status="rejected",
|
||||
admin_note=normalized_admin_note,
|
||||
reviewed_by=admin_user_id,
|
||||
reviewed_at=_now_timestamp(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise RequestServiceError(str(exc), status_code=409, code="stale_transition") from exc
|
||||
|
||||
|
||||
def fulfil_request(
|
||||
user_db: "UserDB",
|
||||
*,
|
||||
request_id: int,
|
||||
admin_user_id: int,
|
||||
queue_release: Callable[..., tuple[bool, str | None]],
|
||||
release_data: Any = None,
|
||||
admin_note: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fulfil a pending request and queue the release under requesting-user identity."""
|
||||
request_row = ensure_request_access(
|
||||
user_db,
|
||||
request_id=request_id,
|
||||
actor_user_id=admin_user_id,
|
||||
is_admin=True,
|
||||
)
|
||||
if request_row["status"] != "pending":
|
||||
raise RequestServiceError(
|
||||
"Request is already in a terminal state",
|
||||
status_code=409,
|
||||
code="stale_transition",
|
||||
)
|
||||
|
||||
normalized_admin_note = None
|
||||
if admin_note is not None:
|
||||
if not isinstance(admin_note, str):
|
||||
raise RequestServiceError("admin_note must be a string", status_code=400)
|
||||
normalized_admin_note = admin_note.strip() or None
|
||||
|
||||
selected_release_data = release_data if release_data is not None else request_row.get("release_data")
|
||||
if selected_release_data is not None and not isinstance(selected_release_data, dict):
|
||||
raise RequestServiceError("release_data must be an object", status_code=400)
|
||||
|
||||
if request_row["request_level"] == "book" and selected_release_data is None:
|
||||
raise RequestServiceError(
|
||||
"release_data is required to fulfil book-level requests",
|
||||
status_code=400,
|
||||
)
|
||||
if request_row["request_level"] == "release" and selected_release_data is None:
|
||||
raise RequestServiceError(
|
||||
"release_data is required to fulfil release-level requests",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
_validate_json_blob_size("release_data", selected_release_data)
|
||||
|
||||
requester = user_db.get_user(user_id=request_row["user_id"])
|
||||
if requester is None:
|
||||
raise RequestServiceError("Requesting user not found", status_code=404)
|
||||
|
||||
success, error = queue_release(
|
||||
selected_release_data,
|
||||
0,
|
||||
user_id=request_row["user_id"],
|
||||
username=requester.get("username"),
|
||||
)
|
||||
if not success:
|
||||
raise RequestServiceError(
|
||||
error or "Failed to queue release",
|
||||
status_code=409,
|
||||
code="queue_failed",
|
||||
)
|
||||
|
||||
try:
|
||||
return user_db.update_request(
|
||||
request_id,
|
||||
status="fulfilled",
|
||||
release_data=selected_release_data,
|
||||
admin_note=normalized_admin_note,
|
||||
reviewed_by=admin_user_id,
|
||||
reviewed_at=_now_timestamp(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise RequestServiceError(str(exc), status_code=409, code="stale_transition") from exc
|
||||
@@ -29,6 +29,7 @@ class FieldBase:
|
||||
disabled_when: Optional[Dict[str, Any]] = None # Conditional disable: {"field": "key", "value": "expected", "reason": "..."}
|
||||
requires_restart: bool = False # Whether changing this setting requires a container restart
|
||||
universal_only: bool = False # Only show in Universal search mode (hide in Direct mode)
|
||||
hidden_in_ui: bool = False # Keep field in schema/save path but hide default renderer
|
||||
|
||||
def get_env_var_name(self) -> str:
|
||||
"""Get the environment variable name for this field."""
|
||||
@@ -117,6 +118,31 @@ class TableField(FieldBase):
|
||||
empty_message: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CustomComponentField:
|
||||
"""Render a custom frontend component inside settings content."""
|
||||
|
||||
key: str
|
||||
component: str # Frontend component registry key
|
||||
label: str = ""
|
||||
description: str = ""
|
||||
bind_keys: List[str] = field(default_factory=list) # Related value keys this component edits
|
||||
value_fields: List[Any] = field(default_factory=list) # Backing value schema for this component
|
||||
wrap_in_field_wrapper: bool = False # Whether to render with standard FieldWrapper layout
|
||||
disabled: bool = False
|
||||
disabled_reason: str = ""
|
||||
show_when: Optional[Dict[str, Any] | List[Dict[str, Any]]] = None
|
||||
universal_only: bool = False
|
||||
|
||||
def get_field_type(self) -> str:
|
||||
return "CustomComponentField"
|
||||
|
||||
def get_bind_keys(self) -> List[str]:
|
||||
if self.bind_keys:
|
||||
return self.bind_keys
|
||||
return [getattr(f, "key") for f in self.value_fields if getattr(f, "key", None)]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionButton:
|
||||
key: str # Action identifier
|
||||
@@ -144,6 +170,7 @@ class HeadingField:
|
||||
key: str # Unique identifier
|
||||
title: str # Heading title
|
||||
description: str = "" # Description text (supports markdown-style links)
|
||||
description_by_auth_mode: Optional[Dict[str, str]] = None # Optional auth-mode specific description map
|
||||
link_url: str = "" # Optional URL for a link
|
||||
link_text: str = "" # Text for the link (defaults to URL if not provided)
|
||||
show_when: Optional[Dict[str, Any] | List[Dict[str, Any]]] = None # Conditional visibility: {"field": "key", "value": "expected"} or list of conditions
|
||||
@@ -164,6 +191,7 @@ SettingsField = Union[
|
||||
TagListField,
|
||||
OrderableListField,
|
||||
TableField,
|
||||
CustomComponentField,
|
||||
ActionButton,
|
||||
HeadingField,
|
||||
]
|
||||
@@ -264,6 +292,12 @@ def get_all_settings_tabs() -> List[SettingsTab]:
|
||||
def _iter_value_fields(tab: SettingsTab):
|
||||
"""Yield value-bearing fields for a tab."""
|
||||
for field in tab.fields:
|
||||
if isinstance(field, CustomComponentField):
|
||||
for value_field in field.value_fields:
|
||||
if isinstance(value_field, (ActionButton, HeadingField, CustomComponentField)):
|
||||
continue
|
||||
yield value_field
|
||||
continue
|
||||
if isinstance(field, (ActionButton, HeadingField)):
|
||||
continue
|
||||
yield field
|
||||
@@ -395,11 +429,7 @@ def initialize_default_configs() -> bool:
|
||||
|
||||
# Collect default values for all fields
|
||||
defaults = {}
|
||||
for field in tab.fields:
|
||||
# Skip non-value fields
|
||||
if isinstance(field, (ActionButton, HeadingField)):
|
||||
continue
|
||||
|
||||
for field in _iter_value_fields(tab):
|
||||
# Only include fields that have a non-None default
|
||||
if field.default is not None:
|
||||
defaults[field.key] = field.default
|
||||
@@ -431,11 +461,7 @@ def sync_env_to_config() -> None:
|
||||
for tab in get_all_settings_tabs():
|
||||
values_to_sync = {}
|
||||
|
||||
for field in tab.fields:
|
||||
# Skip non-value fields
|
||||
if isinstance(field, (ActionButton, HeadingField)):
|
||||
continue
|
||||
|
||||
for field in _iter_value_fields(tab):
|
||||
# Skip fields that don't support ENV vars
|
||||
if not getattr(field, 'env_supported', True):
|
||||
continue
|
||||
@@ -622,7 +648,7 @@ def migrate_legacy_settings() -> None:
|
||||
|
||||
|
||||
def get_setting_value(field: SettingsField, tab_name: str) -> Any:
|
||||
if isinstance(field, (ActionButton, HeadingField)):
|
||||
if isinstance(field, (ActionButton, HeadingField, CustomComponentField)):
|
||||
return None # Actions and headings don't have values
|
||||
|
||||
# 1. Check environment variable (if supported for this field)
|
||||
@@ -677,7 +703,7 @@ def _parse_env_value(value: str, field: SettingsField) -> Any:
|
||||
|
||||
def is_value_from_env(field: SettingsField) -> bool:
|
||||
"""Check if a field's value comes from an environment variable."""
|
||||
if isinstance(field, (ActionButton, HeadingField)):
|
||||
if isinstance(field, (ActionButton, HeadingField, CustomComponentField)):
|
||||
return False
|
||||
# UI-only settings never come from ENV (env_supported=False)
|
||||
if not getattr(field, 'env_supported', True):
|
||||
@@ -697,6 +723,36 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T
|
||||
Returns:
|
||||
Dict representation of the field.
|
||||
"""
|
||||
# CustomComponentField has a custom structure - handle separately
|
||||
if isinstance(field, CustomComponentField):
|
||||
result: Dict[str, Any] = {
|
||||
"key": field.key,
|
||||
"label": field.label,
|
||||
"type": field.get_field_type(),
|
||||
"description": field.description,
|
||||
"component": field.component,
|
||||
"bindKeys": field.get_bind_keys(),
|
||||
"wrapInFieldWrapper": field.wrap_in_field_wrapper,
|
||||
"disabled": field.disabled,
|
||||
"disabledReason": field.disabled_reason,
|
||||
}
|
||||
if field.value_fields:
|
||||
bound_fields = []
|
||||
for value_field in field.value_fields:
|
||||
serialized_bound_field = serialize_field(
|
||||
value_field,
|
||||
tab_name,
|
||||
include_value=include_value,
|
||||
)
|
||||
serialized_bound_field["hiddenInUi"] = True
|
||||
bound_fields.append(serialized_bound_field)
|
||||
result["boundFields"] = bound_fields
|
||||
if field.show_when:
|
||||
result["showWhen"] = field.show_when
|
||||
if field.universal_only:
|
||||
result["universalOnly"] = True
|
||||
return result
|
||||
|
||||
# HeadingField has a different structure - handle separately
|
||||
if isinstance(field, HeadingField):
|
||||
result: Dict[str, Any] = {
|
||||
@@ -705,6 +761,8 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T
|
||||
"title": field.title,
|
||||
"description": field.description,
|
||||
}
|
||||
if field.description_by_auth_mode:
|
||||
result["descriptionByAuthMode"] = field.description_by_auth_mode
|
||||
if field.link_url:
|
||||
result["linkUrl"] = field.link_url
|
||||
result["linkText"] = field.link_text or field.link_url
|
||||
@@ -724,6 +782,7 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T
|
||||
"disabledReason": getattr(field, 'disabled_reason', ''),
|
||||
"requiresRestart": getattr(field, 'requires_restart', False),
|
||||
"userOverridable": getattr(field, 'user_overridable', False),
|
||||
"hiddenInUi": getattr(field, 'hidden_in_ui', False),
|
||||
}
|
||||
|
||||
# Add optional properties if set
|
||||
@@ -774,7 +833,7 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T
|
||||
result["style"] = field.style
|
||||
result["description"] = field.description
|
||||
|
||||
if include_value and not isinstance(field, (ActionButton, HeadingField)):
|
||||
if include_value and not isinstance(field, (ActionButton, HeadingField, CustomComponentField)):
|
||||
value = get_setting_value(field, tab_name)
|
||||
|
||||
# Ensure select values are serialized as strings so the frontend can
|
||||
@@ -953,7 +1012,10 @@ def update_settings(tab_name: str, values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {"success": False, "message": f"Unknown settings tab: {tab_name}", "updated": [], "requiresRestart": False}
|
||||
|
||||
# Build a map of field keys to fields (exclude non-value fields)
|
||||
field_map = {f.key: f for f in tab.fields if not isinstance(f, (ActionButton, HeadingField))}
|
||||
field_map = {
|
||||
key: field
|
||||
for key, (field, _) in get_settings_field_map(tab_name=tab_name).items()
|
||||
}
|
||||
|
||||
# Filter out values that are set via env vars or unknown
|
||||
values_to_save = {}
|
||||
|
||||
@@ -8,6 +8,13 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from shelfmark.core.auth_modes import AUTH_SOURCE_BUILTIN, AUTH_SOURCE_SET
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.requests_service import (
|
||||
normalize_policy_mode,
|
||||
normalize_request_level,
|
||||
normalize_request_status,
|
||||
validate_request_level_payload,
|
||||
validate_status_transition,
|
||||
)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
@@ -28,6 +35,29 @@ CREATE TABLE IF NOT EXISTS user_settings (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
settings_json TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS download_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
source_hint TEXT,
|
||||
content_type TEXT NOT NULL,
|
||||
request_level TEXT NOT NULL,
|
||||
policy_mode TEXT NOT NULL,
|
||||
book_data TEXT NOT NULL,
|
||||
release_data TEXT,
|
||||
note TEXT,
|
||||
admin_note TEXT,
|
||||
reviewed_by INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
reviewed_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_requests_user_status_created_at
|
||||
ON download_requests (user_id, status, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_requests_status_created_at
|
||||
ON download_requests (status, created_at DESC);
|
||||
"""
|
||||
|
||||
|
||||
@@ -278,3 +308,291 @@ class UserDB:
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def _serialize_json(value: Any, field: str) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return json.dumps(value)
|
||||
except TypeError as exc:
|
||||
raise ValueError(f"{field} must be JSON-serializable") from exc
|
||||
|
||||
@staticmethod
|
||||
def _parse_request_row(row: Optional[sqlite3.Row]) -> Optional[Dict[str, Any]]:
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
payload = dict(row)
|
||||
for key in ("book_data", "release_data"):
|
||||
raw_value = payload.get(key)
|
||||
if raw_value is None:
|
||||
payload[key] = None
|
||||
continue
|
||||
try:
|
||||
payload[key] = json.loads(raw_value)
|
||||
except (ValueError, TypeError):
|
||||
payload[key] = None
|
||||
return payload
|
||||
|
||||
def create_request(
|
||||
self,
|
||||
*,
|
||||
user_id: int,
|
||||
content_type: str,
|
||||
request_level: str,
|
||||
policy_mode: str,
|
||||
book_data: Dict[str, Any],
|
||||
release_data: Optional[Dict[str, Any]] = None,
|
||||
status: str = "pending",
|
||||
source_hint: Optional[str] = None,
|
||||
note: Optional[str] = None,
|
||||
admin_note: Optional[str] = None,
|
||||
reviewed_by: Optional[int] = None,
|
||||
reviewed_at: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a download request row and return the created record."""
|
||||
if not isinstance(book_data, dict):
|
||||
raise ValueError("book_data must be an object")
|
||||
if release_data is not None and not isinstance(release_data, dict):
|
||||
raise ValueError("release_data must be an object when provided")
|
||||
if not content_type:
|
||||
raise ValueError("content_type is required")
|
||||
|
||||
normalized_status = normalize_request_status(status)
|
||||
normalized_policy_mode = normalize_policy_mode(policy_mode)
|
||||
normalized_request_level = validate_request_level_payload(request_level, release_data)
|
||||
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT INTO download_requests (
|
||||
user_id,
|
||||
status,
|
||||
source_hint,
|
||||
content_type,
|
||||
request_level,
|
||||
policy_mode,
|
||||
book_data,
|
||||
release_data,
|
||||
note,
|
||||
admin_note,
|
||||
reviewed_by,
|
||||
reviewed_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
normalized_status,
|
||||
source_hint,
|
||||
content_type,
|
||||
normalized_request_level,
|
||||
normalized_policy_mode,
|
||||
self._serialize_json(book_data, "book_data"),
|
||||
self._serialize_json(release_data, "release_data"),
|
||||
note,
|
||||
admin_note,
|
||||
reviewed_by,
|
||||
reviewed_at,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
request_id = cursor.lastrowid
|
||||
row = conn.execute(
|
||||
"SELECT * FROM download_requests WHERE id = ?",
|
||||
(request_id,),
|
||||
).fetchone()
|
||||
parsed = self._parse_request_row(row)
|
||||
if parsed is None:
|
||||
raise ValueError(f"Request {request_id} not found after creation")
|
||||
return parsed
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_request(self, request_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get a request row by ID."""
|
||||
conn = self._connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM download_requests WHERE id = ?",
|
||||
(request_id,),
|
||||
).fetchone()
|
||||
return self._parse_request_row(row)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def list_requests(
|
||||
self,
|
||||
*,
|
||||
user_id: Optional[int] = None,
|
||||
status: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
offset: int = 0,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""List requests with optional user/status filters."""
|
||||
where_clauses: List[str] = []
|
||||
params: List[Any] = []
|
||||
|
||||
if user_id is not None:
|
||||
where_clauses.append("user_id = ?")
|
||||
params.append(user_id)
|
||||
|
||||
if status is not None:
|
||||
where_clauses.append("status = ?")
|
||||
params.append(normalize_request_status(status))
|
||||
|
||||
query = "SELECT * FROM download_requests"
|
||||
if where_clauses:
|
||||
query += " WHERE " + " AND ".join(where_clauses)
|
||||
query += " ORDER BY created_at DESC, id DESC"
|
||||
|
||||
if limit is not None:
|
||||
query += " LIMIT ?"
|
||||
params.append(int(limit))
|
||||
if offset:
|
||||
query += " OFFSET ?"
|
||||
params.append(offset)
|
||||
elif offset:
|
||||
query += " LIMIT -1 OFFSET ?"
|
||||
params.append(offset)
|
||||
|
||||
conn = self._connect()
|
||||
try:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
results: List[Dict[str, Any]] = []
|
||||
for row in rows:
|
||||
parsed = self._parse_request_row(row)
|
||||
if parsed is not None:
|
||||
results.append(parsed)
|
||||
return results
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
_ALLOWED_REQUEST_UPDATE_COLUMNS = {
|
||||
"status",
|
||||
"source_hint",
|
||||
"content_type",
|
||||
"request_level",
|
||||
"policy_mode",
|
||||
"book_data",
|
||||
"release_data",
|
||||
"note",
|
||||
"admin_note",
|
||||
"reviewed_by",
|
||||
"reviewed_at",
|
||||
}
|
||||
|
||||
def update_request(self, request_id: int, **kwargs) -> Dict[str, Any]:
|
||||
"""Update request fields and return the updated record."""
|
||||
if not kwargs:
|
||||
request = self.get_request(request_id)
|
||||
if request is None:
|
||||
raise ValueError(f"Request {request_id} not found")
|
||||
return request
|
||||
|
||||
for key in kwargs:
|
||||
if key not in self._ALLOWED_REQUEST_UPDATE_COLUMNS:
|
||||
raise ValueError(f"Invalid request column: {key}")
|
||||
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM download_requests WHERE id = ?",
|
||||
(request_id,),
|
||||
).fetchone()
|
||||
current = self._parse_request_row(row)
|
||||
if current is None:
|
||||
raise ValueError(f"Request {request_id} not found")
|
||||
|
||||
updates = dict(kwargs)
|
||||
|
||||
if "status" in updates:
|
||||
_, normalized_status = validate_status_transition(
|
||||
current["status"],
|
||||
updates["status"],
|
||||
)
|
||||
updates["status"] = normalized_status
|
||||
|
||||
if "policy_mode" in updates:
|
||||
updates["policy_mode"] = normalize_policy_mode(updates["policy_mode"])
|
||||
|
||||
if "content_type" in updates and not updates["content_type"]:
|
||||
raise ValueError("content_type is required")
|
||||
|
||||
candidate_request_level = updates.get("request_level", current["request_level"])
|
||||
candidate_release_data = (
|
||||
updates["release_data"] if "release_data" in updates else current["release_data"]
|
||||
)
|
||||
candidate_status = updates.get("status", current["status"])
|
||||
normalized_request_level = normalize_request_level(candidate_request_level)
|
||||
normalized_candidate_status = normalize_request_status(candidate_status)
|
||||
|
||||
if normalized_request_level == "release" and candidate_release_data is None:
|
||||
raise ValueError("request_level=release requires non-null release_data")
|
||||
if (
|
||||
normalized_request_level == "book"
|
||||
and candidate_release_data is not None
|
||||
and normalized_candidate_status != "fulfilled"
|
||||
):
|
||||
raise ValueError("request_level=book requires null release_data")
|
||||
if "request_level" in updates:
|
||||
updates["request_level"] = normalized_request_level
|
||||
|
||||
if "book_data" in updates:
|
||||
if not isinstance(updates["book_data"], dict):
|
||||
raise ValueError("book_data must be an object")
|
||||
updates["book_data"] = self._serialize_json(updates["book_data"], "book_data")
|
||||
|
||||
if "release_data" in updates:
|
||||
if updates["release_data"] is not None and not isinstance(updates["release_data"], dict):
|
||||
raise ValueError("release_data must be an object when provided")
|
||||
updates["release_data"] = self._serialize_json(
|
||||
updates["release_data"],
|
||||
"release_data",
|
||||
)
|
||||
|
||||
set_clause = ", ".join(f"{column} = ?" for column in updates)
|
||||
values = list(updates.values()) + [request_id]
|
||||
conn.execute(
|
||||
f"UPDATE download_requests SET {set_clause} WHERE id = ?",
|
||||
values,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
updated_row = conn.execute(
|
||||
"SELECT * FROM download_requests WHERE id = ?",
|
||||
(request_id,),
|
||||
).fetchone()
|
||||
parsed = self._parse_request_row(updated_row)
|
||||
if parsed is None:
|
||||
raise ValueError(f"Request {request_id} not found after update")
|
||||
return parsed
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def count_pending_requests(self) -> int:
|
||||
"""Count all pending requests."""
|
||||
conn = self._connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS count FROM download_requests WHERE status = 'pending'"
|
||||
).fetchone()
|
||||
return int(row["count"]) if row else 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def count_user_pending_requests(self, user_id: int) -> int:
|
||||
"""Count pending requests for a specific user."""
|
||||
conn = self._connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS count FROM download_requests WHERE user_id = ? AND status = 'pending'",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
return int(row["count"]) if row else 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
+260
-22
@@ -3,6 +3,7 @@
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
@@ -36,6 +37,14 @@ from shelfmark.core.auth_modes import (
|
||||
)
|
||||
from shelfmark.core.cwa_user_sync import upsert_cwa_user
|
||||
from shelfmark.core.external_user_linking import upsert_external_user
|
||||
from shelfmark.core.request_policy import (
|
||||
PolicyMode,
|
||||
get_source_content_type_capabilities,
|
||||
merge_request_policy_settings,
|
||||
normalize_content_type,
|
||||
normalize_source,
|
||||
resolve_policy_mode,
|
||||
)
|
||||
from shelfmark.core.utils import normalize_base_path
|
||||
from shelfmark.api.websocket import ws_manager
|
||||
|
||||
@@ -206,6 +215,183 @@ def get_auth_mode() -> str:
|
||||
return "none"
|
||||
|
||||
|
||||
def _load_users_request_policy_settings() -> dict[str, Any]:
|
||||
"""Load global request policy settings from users config."""
|
||||
from shelfmark.core.settings_registry import load_config_file
|
||||
|
||||
return load_config_file("users")
|
||||
|
||||
|
||||
def _as_bool(value: Any, default: bool = False) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "no", "off", ""}:
|
||||
return False
|
||||
return bool(value)
|
||||
|
||||
|
||||
_AUDIOBOOK_CATEGORY_RANGE = (3030, 3049)
|
||||
_AUDIOBOOK_FORMAT_HINTS = frozenset(
|
||||
{
|
||||
"m4b",
|
||||
"mp3",
|
||||
"m4a",
|
||||
"flac",
|
||||
"ogg",
|
||||
"wma",
|
||||
"aac",
|
||||
"wav",
|
||||
"opus",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _contains_audiobook_format_hint(value: Any) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
|
||||
normalized = value.strip().lower()
|
||||
if not normalized:
|
||||
return False
|
||||
|
||||
tokens = [token for token in re.split(r"[^a-z0-9]+", normalized) if token]
|
||||
return any(token in _AUDIOBOOK_FORMAT_HINTS for token in tokens)
|
||||
|
||||
|
||||
def _resolve_release_content_type(data: dict[str, Any], source: Any) -> tuple[str, bool]:
|
||||
"""Resolve release content type for policy checks and queue payload normalization."""
|
||||
extra = data.get("extra")
|
||||
if not isinstance(extra, dict):
|
||||
extra = {}
|
||||
|
||||
explicit_content_type = data.get("content_type")
|
||||
if explicit_content_type is None:
|
||||
explicit_content_type = extra.get("content_type")
|
||||
if explicit_content_type is not None:
|
||||
return normalize_content_type(explicit_content_type), False
|
||||
|
||||
categories = extra.get("categories")
|
||||
if isinstance(categories, list):
|
||||
min_cat, max_cat = _AUDIOBOOK_CATEGORY_RANGE
|
||||
for raw_category in categories:
|
||||
try:
|
||||
category_id = int(raw_category)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if min_cat <= category_id <= max_cat:
|
||||
return "audiobook", True
|
||||
|
||||
candidates: list[Any] = [
|
||||
data.get("format"),
|
||||
extra.get("format"),
|
||||
extra.get("formats_display"),
|
||||
data.get("title"),
|
||||
]
|
||||
formats = extra.get("formats")
|
||||
if isinstance(formats, list):
|
||||
candidates.extend(formats)
|
||||
else:
|
||||
candidates.append(formats)
|
||||
|
||||
if any(_contains_audiobook_format_hint(candidate) for candidate in candidates):
|
||||
return "audiobook", True
|
||||
|
||||
capabilities = get_source_content_type_capabilities()
|
||||
supported = capabilities.get(normalize_source(source))
|
||||
if supported and len(supported) == 1:
|
||||
return normalize_content_type(next(iter(supported))), True
|
||||
|
||||
return "ebook", False
|
||||
|
||||
|
||||
def _resolve_policy_mode_for_current_user(*, source: Any, content_type: Any) -> PolicyMode | None:
|
||||
"""Resolve policy mode for current session, or None when policy guard is bypassed."""
|
||||
auth_mode = get_auth_mode()
|
||||
if auth_mode == "none":
|
||||
return None
|
||||
if session.get("is_admin", True):
|
||||
return None
|
||||
if user_db is None:
|
||||
return None
|
||||
|
||||
global_settings = _load_users_request_policy_settings()
|
||||
db_user_id = session.get("db_user_id")
|
||||
user_settings: dict[str, Any] | None = None
|
||||
if db_user_id is not None:
|
||||
try:
|
||||
user_settings = user_db.get_user_settings(int(db_user_id))
|
||||
except (TypeError, ValueError):
|
||||
user_settings = None
|
||||
|
||||
effective = merge_request_policy_settings(global_settings, user_settings)
|
||||
if not _as_bool(effective.get("REQUESTS_ENABLED"), False):
|
||||
return None
|
||||
|
||||
resolved_mode = resolve_policy_mode(
|
||||
source=source,
|
||||
content_type=content_type,
|
||||
global_settings=global_settings,
|
||||
user_settings=user_settings,
|
||||
)
|
||||
logger.debug(
|
||||
"download policy resolve user=%s db_user_id=%s is_admin=%s source=%s content_type=%s mode=%s",
|
||||
session.get("user_id"),
|
||||
db_user_id,
|
||||
bool(session.get("is_admin", False)),
|
||||
source,
|
||||
content_type,
|
||||
resolved_mode.value,
|
||||
)
|
||||
return resolved_mode
|
||||
|
||||
|
||||
def _policy_block_response(mode: PolicyMode):
|
||||
logger.debug(
|
||||
"download policy guard user=%s db_user_id=%s mode=%s",
|
||||
session.get("user_id"),
|
||||
session.get("db_user_id"),
|
||||
mode.value,
|
||||
)
|
||||
if mode == PolicyMode.BLOCKED:
|
||||
return (
|
||||
jsonify({
|
||||
"error": "Download not allowed by policy",
|
||||
"code": "policy_blocked",
|
||||
"required_mode": PolicyMode.BLOCKED.value,
|
||||
}),
|
||||
403,
|
||||
)
|
||||
return (
|
||||
jsonify({
|
||||
"error": "Download not allowed by policy",
|
||||
"code": "policy_requires_request",
|
||||
"required_mode": mode.value,
|
||||
}),
|
||||
403,
|
||||
)
|
||||
|
||||
|
||||
if user_db is not None:
|
||||
try:
|
||||
from shelfmark.core.request_routes import register_request_routes
|
||||
|
||||
register_request_routes(
|
||||
app,
|
||||
user_db,
|
||||
resolve_auth_mode=lambda: get_auth_mode(),
|
||||
queue_release=lambda *args, **kwargs: backend.queue_release(*args, **kwargs),
|
||||
ws_manager=ws_manager,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to register request routes: {e}")
|
||||
|
||||
|
||||
# Enable CORS in development mode for local frontend development
|
||||
if DEBUG:
|
||||
CORS(app, resources={
|
||||
@@ -609,6 +795,13 @@ def api_download() -> Union[Response, Tuple[Response, int]]:
|
||||
return jsonify({"error": "No book ID provided"}), 400
|
||||
|
||||
try:
|
||||
policy_mode = _resolve_policy_mode_for_current_user(
|
||||
source="direct_download",
|
||||
content_type="ebook",
|
||||
)
|
||||
if policy_mode is not None and policy_mode != PolicyMode.DOWNLOAD:
|
||||
return _policy_block_response(policy_mode)
|
||||
|
||||
priority = int(request.args.get('priority', 0))
|
||||
# Per-user download overrides
|
||||
db_user_id = session.get('db_user_id')
|
||||
@@ -653,12 +846,26 @@ def api_download_release() -> Union[Response, Tuple[Response, int]]:
|
||||
if 'source_id' not in data:
|
||||
return jsonify({"error": "source_id is required"}), 400
|
||||
|
||||
source = data.get('source', 'direct_download')
|
||||
resolved_content_type, inferred_content_type = _resolve_release_content_type(data, source)
|
||||
policy_mode = _resolve_policy_mode_for_current_user(
|
||||
source=source,
|
||||
content_type=resolved_content_type,
|
||||
)
|
||||
if policy_mode is not None and policy_mode != PolicyMode.DOWNLOAD:
|
||||
return _policy_block_response(policy_mode)
|
||||
|
||||
release_payload = data
|
||||
if inferred_content_type and data.get("content_type") is None:
|
||||
release_payload = dict(data)
|
||||
release_payload["content_type"] = resolved_content_type
|
||||
|
||||
priority = data.get('priority', 0)
|
||||
# Per-user download overrides
|
||||
db_user_id = session.get('db_user_id')
|
||||
_username = session.get('user_id')
|
||||
success, error_msg = backend.queue_release(
|
||||
data, priority,
|
||||
release_payload, priority,
|
||||
user_id=db_user_id, username=_username,
|
||||
)
|
||||
|
||||
@@ -733,6 +940,36 @@ def api_health() -> Union[Response, Tuple[Response, int]]:
|
||||
|
||||
return jsonify(response)
|
||||
|
||||
|
||||
def _resolve_status_scope(*, require_authenticated: bool = True) -> tuple[bool, int | None, bool]:
|
||||
"""Resolve queue-status visibility from session state.
|
||||
|
||||
Returns:
|
||||
(is_admin, db_user_id, can_access_status)
|
||||
"""
|
||||
auth_mode = get_auth_mode()
|
||||
if auth_mode == "none":
|
||||
return True, None, True
|
||||
|
||||
if require_authenticated and 'user_id' not in session:
|
||||
return False, None, False
|
||||
|
||||
is_admin = bool(session.get('is_admin', False))
|
||||
if is_admin:
|
||||
return True, None, True
|
||||
|
||||
raw_db_user_id = session.get('db_user_id')
|
||||
try:
|
||||
db_user_id = int(raw_db_user_id) if raw_db_user_id is not None else None
|
||||
except (TypeError, ValueError):
|
||||
db_user_id = None
|
||||
|
||||
if db_user_id is None:
|
||||
return False, None, False
|
||||
|
||||
return False, db_user_id, True
|
||||
|
||||
|
||||
@app.route('/api/status', methods=['GET'])
|
||||
@login_required
|
||||
def api_status() -> Union[Response, Tuple[Response, int]]:
|
||||
@@ -743,10 +980,11 @@ def api_status() -> Union[Response, Tuple[Response, int]]:
|
||||
flask.Response: JSON object with queue status.
|
||||
"""
|
||||
try:
|
||||
# Non-admin users only see their own downloads
|
||||
user_id = None
|
||||
if not session.get('is_admin', True):
|
||||
user_id = session.get('db_user_id')
|
||||
is_admin, db_user_id, can_access_status = _resolve_status_scope()
|
||||
if not can_access_status:
|
||||
return jsonify({})
|
||||
|
||||
user_id = None if is_admin else db_user_id
|
||||
status = backend.queue_status(user_id=user_id)
|
||||
return jsonify(status)
|
||||
except Exception as e:
|
||||
@@ -1246,12 +1484,8 @@ def api_auth_check() -> Union[Response, Tuple[Response, int]]:
|
||||
is_admin = get_auth_check_admin_status(auth_mode, users_config, session)
|
||||
|
||||
display_name = None
|
||||
if is_authenticated and session.get('db_user_id'):
|
||||
if is_authenticated and session.get('db_user_id') and user_db is not None:
|
||||
try:
|
||||
from shelfmark.core.user_db import UserDB
|
||||
import os
|
||||
user_db = UserDB(os.path.join(os.environ.get("CONFIG_DIR", "/config"), "users.db"))
|
||||
user_db.initialize()
|
||||
db_user = user_db.get_user(user_id=session['db_user_id'])
|
||||
if db_user:
|
||||
display_name = db_user.get("display_name") or None
|
||||
@@ -1921,16 +2155,17 @@ def handle_connect():
|
||||
# Track the connection (triggers warmup callbacks on first connect)
|
||||
ws_manager.client_connected()
|
||||
|
||||
# Join appropriate room based on user session
|
||||
is_admin = session.get('is_admin', True)
|
||||
db_user_id = session.get('db_user_id')
|
||||
# Join appropriate room based on authenticated user session
|
||||
is_admin, db_user_id, can_access_status = _resolve_status_scope()
|
||||
ws_manager.join_user_room(request.sid, is_admin, db_user_id)
|
||||
|
||||
# Send initial status to the newly connected client (filtered)
|
||||
try:
|
||||
user_id = None
|
||||
if not is_admin:
|
||||
user_id = db_user_id
|
||||
if not can_access_status:
|
||||
emit('status_update', {})
|
||||
return
|
||||
|
||||
user_id = None if is_admin else db_user_id
|
||||
status = backend.queue_status(user_id=user_id)
|
||||
emit('status_update', status)
|
||||
except Exception as e:
|
||||
@@ -1942,9 +2177,7 @@ def handle_disconnect():
|
||||
logger.info("WebSocket client disconnected")
|
||||
|
||||
# Leave room
|
||||
is_admin = session.get('is_admin', True)
|
||||
db_user_id = session.get('db_user_id')
|
||||
ws_manager.leave_user_room(request.sid, is_admin, db_user_id)
|
||||
ws_manager.leave_user_room(request.sid)
|
||||
|
||||
# Track the disconnection
|
||||
ws_manager.client_disconnected()
|
||||
@@ -1953,9 +2186,14 @@ def handle_disconnect():
|
||||
def handle_status_request():
|
||||
"""Handle manual status request from client."""
|
||||
try:
|
||||
user_id = None
|
||||
if not session.get('is_admin', True):
|
||||
user_id = session.get('db_user_id')
|
||||
is_admin, db_user_id, can_access_status = _resolve_status_scope()
|
||||
ws_manager.sync_user_room(request.sid, is_admin, db_user_id)
|
||||
|
||||
if not can_access_status:
|
||||
emit('status_update', {})
|
||||
return
|
||||
|
||||
user_id = None if is_admin else db_user_id
|
||||
status = backend.queue_status(user_id=user_id)
|
||||
emit('status_update', status)
|
||||
except Exception as e:
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:unit": "npm run test:unit:build && node --experimental-specifier-resolution=node --test ../../.local/frontend-test-dist/tests/**/*.node.test.js",
|
||||
"test:unit:build": "tsc -p tsconfig.tests.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
|
||||
+686
-76
@@ -3,26 +3,44 @@ import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import {
|
||||
Book,
|
||||
Release,
|
||||
RequestRecord,
|
||||
StatusData,
|
||||
AppConfig,
|
||||
ContentType,
|
||||
ButtonStateInfo,
|
||||
RequestPolicyMode,
|
||||
CreateRequestPayload,
|
||||
} from './types';
|
||||
import { getBookInfo, getMetadataBookInfo, downloadBook, downloadRelease, cancelDownload, clearCompleted, getConfig } from './services/api';
|
||||
import {
|
||||
getBookInfo,
|
||||
getMetadataBookInfo,
|
||||
downloadBook,
|
||||
downloadRelease,
|
||||
cancelDownload,
|
||||
clearCompleted,
|
||||
getConfig,
|
||||
createRequest,
|
||||
isApiResponseError,
|
||||
} from './services/api';
|
||||
import { useToast } from './hooks/useToast';
|
||||
import { useRealtimeStatus } from './hooks/useRealtimeStatus';
|
||||
import { useAuth } from './hooks/useAuth';
|
||||
import { useSearch } from './hooks/useSearch';
|
||||
import { useUrlSearch } from './hooks/useUrlSearch';
|
||||
import { useDownloadTracking } from './hooks/useDownloadTracking';
|
||||
import { useRequestPolicy } from './hooks/useRequestPolicy';
|
||||
import { resolveDefaultModeFromPolicy, resolveSourceModeFromPolicy } from './hooks/requestPolicyCore';
|
||||
import { useRequests } from './hooks/useRequests';
|
||||
import { Header } from './components/Header';
|
||||
import { SearchSection } from './components/SearchSection';
|
||||
import { AdvancedFilters } from './components/AdvancedFilters';
|
||||
import { ResultsSection } from './components/ResultsSection';
|
||||
import { DetailsModal } from './components/DetailsModal';
|
||||
import { ReleaseModal } from './components/ReleaseModal';
|
||||
import { DownloadsSidebar } from './components/DownloadsSidebar';
|
||||
import { RequestConfirmationModal } from './components/RequestConfirmationModal';
|
||||
import { ToastContainer } from './components/ToastContainer';
|
||||
import { Footer } from './components/Footer';
|
||||
import { ActivitySidebar, requestToActivityItem } from './components/activity';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { SettingsModal } from './components/settings';
|
||||
import { ConfigSetupBanner } from './components/ConfigSetupBanner';
|
||||
@@ -30,6 +48,19 @@ import { OnboardingModal } from './components/OnboardingModal';
|
||||
import { DEFAULT_LANGUAGES, DEFAULT_SUPPORTED_FORMATS } from './data/languages';
|
||||
import { buildSearchQuery } from './utils/buildSearchQuery';
|
||||
import { withBasePath } from './utils/basePath';
|
||||
import {
|
||||
applyDirectPolicyModeToButtonState,
|
||||
applyUniversalPolicyModeToButtonState,
|
||||
} from './utils/requestPolicyUi';
|
||||
import {
|
||||
buildDirectRequestPayload,
|
||||
buildMetadataBookRequestData,
|
||||
buildReleaseDataFromMetadataRelease,
|
||||
getRequestSuccessMessage,
|
||||
toContentType,
|
||||
} from './utils/requestPayload';
|
||||
import { bookFromRequestData } from './utils/requestFulfil';
|
||||
import { policyTrace } from './utils/policyTrace';
|
||||
import { SearchModeProvider } from './contexts/SearchModeContext';
|
||||
import './styles.css';
|
||||
|
||||
@@ -47,6 +78,43 @@ const getInitialContentType = (): ContentType => {
|
||||
return 'ebook';
|
||||
};
|
||||
|
||||
const POLICY_GUARD_ERROR_CODES = new Set(['policy_requires_request', 'policy_blocked']);
|
||||
|
||||
const isPolicyGuardError = (error: unknown): boolean => {
|
||||
return (
|
||||
isApiResponseError(error) &&
|
||||
error.status === 403 &&
|
||||
Boolean(error.code && POLICY_GUARD_ERROR_CODES.has(error.code))
|
||||
);
|
||||
};
|
||||
|
||||
const asRequestPolicyMode = (value: unknown): RequestPolicyMode | null => {
|
||||
return value === 'download' || value === 'request_release' || value === 'request_book' || value === 'blocked'
|
||||
? value
|
||||
: null;
|
||||
};
|
||||
|
||||
const getPolicyGuardRequiredMode = (error: unknown): RequestPolicyMode | null => {
|
||||
if (!isPolicyGuardError(error) || !isApiResponseError(error)) {
|
||||
return null;
|
||||
}
|
||||
const explicitMode = asRequestPolicyMode(error.requiredMode);
|
||||
if (explicitMode) {
|
||||
return explicitMode;
|
||||
}
|
||||
if (error.code === 'policy_blocked') {
|
||||
return 'blocked';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getErrorMessage = (error: unknown, fallback: string): string => {
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
function App() {
|
||||
const { toasts, showToast, removeToast } = useToast();
|
||||
|
||||
@@ -76,7 +144,7 @@ function App() {
|
||||
isAuthenticated,
|
||||
authRequired,
|
||||
authChecked,
|
||||
isAdmin,
|
||||
isAdmin: authCanAccessSettings,
|
||||
authMode,
|
||||
username,
|
||||
displayName,
|
||||
@@ -90,6 +158,15 @@ function App() {
|
||||
showToast,
|
||||
});
|
||||
|
||||
// Re-request status after auth is established so the server can re-scope socket room membership.
|
||||
useEffect(() => {
|
||||
if (!authChecked || !isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
policyTrace('auth.status', { authChecked, isAuthenticated, isAdmin: authCanAccessSettings, username });
|
||||
void fetchStatus();
|
||||
}, [authChecked, isAuthenticated, authCanAccessSettings, username, fetchStatus]);
|
||||
|
||||
// Content type state (ebook vs audiobook) - defined before useSearch since it's passed to it
|
||||
const [contentType, setContentType] = useState<ContentType>(() => getInitialContentType());
|
||||
|
||||
@@ -101,6 +178,101 @@ function App() {
|
||||
}
|
||||
}, [contentType]);
|
||||
|
||||
const {
|
||||
policy: requestPolicy,
|
||||
getDefaultMode,
|
||||
getSourceMode,
|
||||
requestsEnabled: requestsPolicyEnabled,
|
||||
allowNotes: allowRequestNotes,
|
||||
refresh: refreshRequestPolicy,
|
||||
} = useRequestPolicy({
|
||||
enabled: isAuthenticated,
|
||||
isAdmin: authCanAccessSettings,
|
||||
});
|
||||
|
||||
const requestRoleIsAdmin = requestPolicy ? Boolean(requestPolicy.is_admin) : false;
|
||||
|
||||
const {
|
||||
requests,
|
||||
pendingCount: pendingRequestCount,
|
||||
isLoading: isRequestsLoading,
|
||||
cancelRequest: cancelUserRequest,
|
||||
fulfilRequest: fulfilSidebarRequest,
|
||||
rejectRequest: rejectSidebarRequest,
|
||||
} = useRequests({
|
||||
isAdmin: requestRoleIsAdmin,
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const dismissedRequestStorageKey = useMemo(() => {
|
||||
const roleScope = requestRoleIsAdmin ? 'admin' : 'user';
|
||||
const userScope = username?.trim().toLowerCase() || 'anonymous';
|
||||
return `activity-dismissed-requests:${roleScope}:${userScope}`;
|
||||
}, [requestRoleIsAdmin, username]);
|
||||
|
||||
const [dismissedRequestIds, setDismissedRequestIds] = useState<number[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
setDismissedRequestIds([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(dismissedRequestStorageKey);
|
||||
if (!raw) {
|
||||
setDismissedRequestIds([]);
|
||||
return;
|
||||
}
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
setDismissedRequestIds([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const ids = parsed.filter((value): value is number => typeof value === 'number' && Number.isFinite(value));
|
||||
setDismissedRequestIds(ids);
|
||||
} catch {
|
||||
setDismissedRequestIds([]);
|
||||
}
|
||||
}, [dismissedRequestStorageKey, isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(dismissedRequestStorageKey, JSON.stringify(dismissedRequestIds));
|
||||
} catch {
|
||||
// Ignore storage failures in restricted/private contexts.
|
||||
}
|
||||
}, [dismissedRequestIds, dismissedRequestStorageKey, isAuthenticated]);
|
||||
|
||||
const requestItems = useMemo(
|
||||
() =>
|
||||
requests
|
||||
.filter((record) => !dismissedRequestIds.includes(record.id))
|
||||
.map((record) => requestToActivityItem(record, requestRoleIsAdmin ? 'admin' : 'user'))
|
||||
.sort((left, right) => right.timestamp - left.timestamp),
|
||||
[requests, requestRoleIsAdmin, dismissedRequestIds]
|
||||
);
|
||||
|
||||
const showRequestsTab = useMemo(() => {
|
||||
if (requestRoleIsAdmin) {
|
||||
return true;
|
||||
}
|
||||
if (!isAuthenticated || !requestsPolicyEnabled) {
|
||||
return false;
|
||||
}
|
||||
if (!requestPolicy) {
|
||||
return false;
|
||||
}
|
||||
return !(
|
||||
requestPolicy.defaults.ebook === 'download' &&
|
||||
requestPolicy.defaults.audiobook === 'download'
|
||||
);
|
||||
}, [requestRoleIsAdmin, isAuthenticated, requestsPolicyEnabled, requestPolicy]);
|
||||
|
||||
// Search state and handlers
|
||||
const {
|
||||
books,
|
||||
@@ -131,11 +303,20 @@ function App() {
|
||||
contentType,
|
||||
});
|
||||
|
||||
const [pendingRequestPayload, setPendingRequestPayload] = useState<CreateRequestPayload | null>(null);
|
||||
const [fulfillingRequest, setFulfillingRequest] = useState<{
|
||||
requestId: number;
|
||||
book: Book;
|
||||
contentType: ContentType;
|
||||
} | null>(null);
|
||||
|
||||
// Wire up logout callback to clear search state
|
||||
const handleLogoutWithCleanup = useCallback(async () => {
|
||||
await handleLogout();
|
||||
setBooks([]);
|
||||
clearTracking();
|
||||
setPendingRequestPayload(null);
|
||||
setFulfillingRequest(null);
|
||||
}, [handleLogout, setBooks, clearTracking]);
|
||||
|
||||
// UI state
|
||||
@@ -143,6 +324,22 @@ function App() {
|
||||
const [releaseBook, setReleaseBook] = useState<Book | null>(null);
|
||||
const [config, setConfig] = useState<AppConfig | null>(null);
|
||||
const [downloadsSidebarOpen, setDownloadsSidebarOpen] = useState(false);
|
||||
const [sidebarPinnedOpen, setSidebarPinnedOpen] = useState(false);
|
||||
const [headerHeight, setHeaderHeight] = useState(0);
|
||||
const headerObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const headerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
if (headerObserverRef.current) {
|
||||
headerObserverRef.current.disconnect();
|
||||
headerObserverRef.current = null;
|
||||
}
|
||||
if (!el) return;
|
||||
setHeaderHeight(el.getBoundingClientRect().height);
|
||||
const observer = new ResizeObserver(() => {
|
||||
setHeaderHeight(el.getBoundingClientRect().height);
|
||||
});
|
||||
observer.observe(el);
|
||||
headerObserverRef.current = observer;
|
||||
}, []);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [configBannerOpen, setConfigBannerOpen] = useState(false);
|
||||
const [onboardingOpen, setOnboardingOpen] = useState(false);
|
||||
@@ -179,8 +376,13 @@ function App() {
|
||||
|
||||
const errored = currentStatus.error ? Object.keys(currentStatus.error).length : 0;
|
||||
|
||||
return { ongoing, completed, errored };
|
||||
}, [currentStatus]);
|
||||
return {
|
||||
ongoing,
|
||||
completed,
|
||||
errored,
|
||||
pendingRequests: pendingRequestCount,
|
||||
};
|
||||
}, [currentStatus, pendingRequestCount]);
|
||||
|
||||
|
||||
// Compute visibility states
|
||||
@@ -317,6 +519,14 @@ function App() {
|
||||
}
|
||||
}, [isAuthenticated, loadConfig]);
|
||||
|
||||
const runSearchWithPolicyRefresh = useCallback(
|
||||
(query: string, fields = searchFieldValues) => {
|
||||
void refreshRequestPolicy();
|
||||
handleSearch(query, config, fields);
|
||||
},
|
||||
[refreshRequestPolicy, handleSearch, config, searchFieldValues]
|
||||
);
|
||||
|
||||
// Execute URL-based search when params are present
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -370,7 +580,7 @@ function App() {
|
||||
searchMode,
|
||||
});
|
||||
|
||||
handleSearch(query, config, searchFieldValues);
|
||||
runSearchWithPolicyRefresh(query);
|
||||
}
|
||||
}, [
|
||||
wasProcessed,
|
||||
@@ -378,7 +588,7 @@ function App() {
|
||||
config,
|
||||
advancedFilters,
|
||||
searchFieldValues,
|
||||
handleSearch,
|
||||
runSearchWithPolicyRefresh,
|
||||
setSearchInput,
|
||||
setAdvancedFilters,
|
||||
setShowAdvanced,
|
||||
@@ -437,14 +647,112 @@ function App() {
|
||||
setReleaseBook(book);
|
||||
};
|
||||
|
||||
// Download book
|
||||
const submitRequest = useCallback(
|
||||
async (payload: CreateRequestPayload, successMessage: string): Promise<boolean> => {
|
||||
try {
|
||||
await createRequest(payload);
|
||||
showToast(successMessage, 'success');
|
||||
await refreshRequestPolicy({ force: true });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Request creation failed:', error);
|
||||
showToast(getErrorMessage(error, 'Failed to create request'), 'error');
|
||||
if (isPolicyGuardError(error)) {
|
||||
await refreshRequestPolicy({ force: true });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[showToast, refreshRequestPolicy]
|
||||
);
|
||||
|
||||
const openRequestConfirmation = useCallback((payload: CreateRequestPayload) => {
|
||||
setPendingRequestPayload(payload);
|
||||
}, []);
|
||||
|
||||
const handleConfirmRequest = useCallback(
|
||||
async (payload: CreateRequestPayload): Promise<boolean> => {
|
||||
const success = await submitRequest(payload, getRequestSuccessMessage(payload));
|
||||
if (success) {
|
||||
setPendingRequestPayload(null);
|
||||
}
|
||||
return success;
|
||||
},
|
||||
[submitRequest]
|
||||
);
|
||||
|
||||
const getDirectPolicyMode = useCallback((): RequestPolicyMode => {
|
||||
return getSourceMode('direct_download', 'ebook');
|
||||
}, [getSourceMode]);
|
||||
|
||||
const getUniversalDefaultPolicyMode = useCallback((): RequestPolicyMode => {
|
||||
return getDefaultMode(contentType);
|
||||
}, [getDefaultMode, contentType]);
|
||||
|
||||
// Direct-mode action (download or release-level request based on policy).
|
||||
const handleDownload = async (book: Book): Promise<void> => {
|
||||
let mode = getDirectPolicyMode();
|
||||
policyTrace('direct.action:start', {
|
||||
bookId: book.id,
|
||||
contentType: 'ebook',
|
||||
cachedMode: mode,
|
||||
isAdmin: requestRoleIsAdmin,
|
||||
});
|
||||
try {
|
||||
const latestPolicy = await refreshRequestPolicy({ force: true });
|
||||
const effectiveIsAdmin = latestPolicy ? Boolean(latestPolicy.is_admin) : requestRoleIsAdmin;
|
||||
mode = resolveSourceModeFromPolicy(latestPolicy, effectiveIsAdmin, 'direct_download', 'ebook');
|
||||
policyTrace('direct.action:resolved', {
|
||||
bookId: book.id,
|
||||
resolvedMode: mode,
|
||||
effectiveIsAdmin,
|
||||
defaults: latestPolicy?.defaults ?? null,
|
||||
requestsEnabled: latestPolicy?.requests_enabled ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Failed to refresh request policy before direct action:', error);
|
||||
policyTrace('direct.action:refresh_failed', {
|
||||
bookId: book.id,
|
||||
mode,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === 'blocked') {
|
||||
policyTrace('direct.action:block', { bookId: book.id, mode });
|
||||
showToast('Download blocked by policy', 'error');
|
||||
await refreshRequestPolicy({ force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'request_release' || mode === 'request_book') {
|
||||
policyTrace('direct.action:request_modal', { bookId: book.id, mode });
|
||||
openRequestConfirmation(buildDirectRequestPayload(book, mode));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadBook(book.id);
|
||||
await fetchStatus();
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
showToast(error instanceof Error ? error.message : 'Failed to queue download', 'error');
|
||||
if (isPolicyGuardError(error)) {
|
||||
const requiredMode = getPolicyGuardRequiredMode(error);
|
||||
policyTrace('direct.action:policy_guard', {
|
||||
bookId: book.id,
|
||||
requiredMode,
|
||||
code: isApiResponseError(error) ? error.code : null,
|
||||
});
|
||||
if (requiredMode === 'request_release' || requiredMode === 'request_book') {
|
||||
openRequestConfirmation(buildDirectRequestPayload(book, requiredMode));
|
||||
await refreshRequestPolicy({ force: true });
|
||||
return;
|
||||
}
|
||||
showToast('Download blocked by policy', 'error');
|
||||
await refreshRequestPolicy({ force: true });
|
||||
return;
|
||||
}
|
||||
showToast(getErrorMessage(error, 'Failed to queue download'), 'error');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -471,10 +779,68 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
// Open release modal
|
||||
// Universal-mode "Get" action (open releases, request-book, or block by policy).
|
||||
const handleGetReleases = async (book: Book) => {
|
||||
let mode = getUniversalDefaultPolicyMode();
|
||||
const normalizedContentType = toContentType(contentType);
|
||||
policyTrace('universal.get:start', {
|
||||
bookId: book.id,
|
||||
contentType: normalizedContentType,
|
||||
cachedMode: mode,
|
||||
isAdmin: requestRoleIsAdmin,
|
||||
});
|
||||
try {
|
||||
const latestPolicy = await refreshRequestPolicy({ force: true });
|
||||
const effectiveIsAdmin = latestPolicy ? Boolean(latestPolicy.is_admin) : requestRoleIsAdmin;
|
||||
mode = resolveDefaultModeFromPolicy(latestPolicy, effectiveIsAdmin, contentType);
|
||||
policyTrace('universal.get:resolved', {
|
||||
bookId: book.id,
|
||||
contentType: normalizedContentType,
|
||||
resolvedMode: mode,
|
||||
effectiveIsAdmin,
|
||||
defaults: latestPolicy?.defaults ?? null,
|
||||
requestsEnabled: latestPolicy?.requests_enabled ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Failed to refresh request policy before universal action:', error);
|
||||
policyTrace('universal.get:refresh_failed', {
|
||||
bookId: book.id,
|
||||
contentType: normalizedContentType,
|
||||
mode,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === 'blocked') {
|
||||
policyTrace('universal.get:block', { bookId: book.id, contentType: normalizedContentType });
|
||||
showToast('This title is unavailable by policy', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'request_book') {
|
||||
policyTrace('universal.get:request_modal', {
|
||||
bookId: book.id,
|
||||
requestLevel: 'book',
|
||||
contentType: normalizedContentType,
|
||||
});
|
||||
openRequestConfirmation({
|
||||
book_data: buildMetadataBookRequestData(book, normalizedContentType),
|
||||
release_data: null,
|
||||
context: {
|
||||
source: '*',
|
||||
content_type: normalizedContentType,
|
||||
request_level: 'book',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (book.provider && book.provider_id) {
|
||||
try {
|
||||
policyTrace('universal.get:open_release_modal', {
|
||||
bookId: book.id,
|
||||
contentType: normalizedContentType,
|
||||
});
|
||||
const fullBook = await getMetadataBookInfo(book.provider, book.provider_id);
|
||||
setReleaseBook({
|
||||
...book,
|
||||
@@ -485,16 +851,31 @@ function App() {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load book description, using search data:', error);
|
||||
policyTrace('universal.get:open_release_modal_fallback', {
|
||||
bookId: book.id,
|
||||
contentType: normalizedContentType,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
setReleaseBook(book);
|
||||
}
|
||||
} else {
|
||||
policyTrace('universal.get:open_release_modal_no_provider', {
|
||||
bookId: book.id,
|
||||
contentType: normalizedContentType,
|
||||
});
|
||||
setReleaseBook(book);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle download from ReleaseModal
|
||||
// Handle download from ReleaseModal (universal mode release rows).
|
||||
const handleReleaseDownload = async (book: Book, release: Release, releaseContentType: ContentType) => {
|
||||
try {
|
||||
policyTrace('release.action:start', {
|
||||
bookId: book.id,
|
||||
releaseId: release.source_id,
|
||||
source: release.source,
|
||||
contentType: toContentType(releaseContentType),
|
||||
});
|
||||
trackRelease(book.id, release.source_id);
|
||||
|
||||
await downloadRelease({
|
||||
@@ -520,11 +901,172 @@ function App() {
|
||||
await fetchStatus();
|
||||
} catch (error) {
|
||||
console.error('Release download failed:', error);
|
||||
showToast(error instanceof Error ? error.message : 'Failed to queue download', 'error');
|
||||
if (isPolicyGuardError(error)) {
|
||||
const requiredMode = getPolicyGuardRequiredMode(error);
|
||||
const normalizedContentType = toContentType(releaseContentType);
|
||||
policyTrace('release.action:policy_guard', {
|
||||
bookId: book.id,
|
||||
releaseId: release.source_id,
|
||||
source: release.source,
|
||||
requiredMode,
|
||||
code: isApiResponseError(error) ? error.code : null,
|
||||
contentType: normalizedContentType,
|
||||
});
|
||||
if (requiredMode === 'request_release') {
|
||||
openRequestConfirmation({
|
||||
book_data: buildMetadataBookRequestData(book, normalizedContentType),
|
||||
release_data: buildReleaseDataFromMetadataRelease(book, release, normalizedContentType),
|
||||
context: {
|
||||
source: release.source || 'direct_download',
|
||||
content_type: normalizedContentType,
|
||||
request_level: 'release',
|
||||
},
|
||||
});
|
||||
await refreshRequestPolicy({ force: true });
|
||||
return;
|
||||
}
|
||||
if (requiredMode === 'request_book') {
|
||||
setReleaseBook(null);
|
||||
openRequestConfirmation({
|
||||
book_data: buildMetadataBookRequestData(book, normalizedContentType),
|
||||
release_data: null,
|
||||
context: {
|
||||
source: release.source || 'direct_download',
|
||||
content_type: normalizedContentType,
|
||||
request_level: 'book',
|
||||
},
|
||||
});
|
||||
await refreshRequestPolicy({ force: true });
|
||||
return;
|
||||
}
|
||||
showToast('Download blocked by policy', 'error');
|
||||
await refreshRequestPolicy({ force: true });
|
||||
return;
|
||||
}
|
||||
showToast(getErrorMessage(error, 'Failed to queue download'), 'error');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleReleaseRequest = useCallback(
|
||||
async (book: Book, release: Release, releaseContentType: ContentType): Promise<void> => {
|
||||
void refreshRequestPolicy();
|
||||
const normalizedContentType = toContentType(releaseContentType);
|
||||
openRequestConfirmation({
|
||||
book_data: buildMetadataBookRequestData(book, normalizedContentType),
|
||||
release_data: buildReleaseDataFromMetadataRelease(book, release, normalizedContentType),
|
||||
context: {
|
||||
source: release.source || 'direct_download',
|
||||
content_type: normalizedContentType,
|
||||
request_level: 'release',
|
||||
},
|
||||
});
|
||||
},
|
||||
[openRequestConfirmation, refreshRequestPolicy]
|
||||
);
|
||||
|
||||
const handleRequestCancel = useCallback(
|
||||
async (requestId: number) => {
|
||||
try {
|
||||
await cancelUserRequest(requestId);
|
||||
showToast('Request cancelled', 'success');
|
||||
} catch (error) {
|
||||
showToast(getErrorMessage(error, 'Failed to cancel request'), 'error');
|
||||
}
|
||||
},
|
||||
[cancelUserRequest, showToast]
|
||||
);
|
||||
|
||||
const handleRequestDismiss = useCallback((requestId: number) => {
|
||||
setDismissedRequestIds((previous) =>
|
||||
previous.includes(requestId) ? previous : [...previous, requestId]
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleRequestReject = useCallback(
|
||||
async (requestId: number, adminNote?: string) => {
|
||||
if (!requestRoleIsAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await rejectSidebarRequest(requestId, adminNote);
|
||||
showToast('Request rejected', 'success');
|
||||
} catch (error) {
|
||||
showToast(getErrorMessage(error, 'Failed to reject request'), 'error');
|
||||
}
|
||||
},
|
||||
[requestRoleIsAdmin, rejectSidebarRequest, showToast]
|
||||
);
|
||||
|
||||
const handleRequestApprove = useCallback(
|
||||
async (requestId: number, record: RequestRecord) => {
|
||||
if (!requestRoleIsAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (record.request_level === 'release') {
|
||||
try {
|
||||
await fulfilSidebarRequest(requestId, record.release_data || undefined);
|
||||
showToast('Request approved', 'success');
|
||||
await fetchStatus();
|
||||
} catch (error) {
|
||||
showToast(getErrorMessage(error, 'Failed to approve request'), 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setReleaseBook(null);
|
||||
setFulfillingRequest({
|
||||
requestId,
|
||||
book: bookFromRequestData(record.book_data),
|
||||
contentType: record.content_type,
|
||||
});
|
||||
},
|
||||
[requestRoleIsAdmin, fulfilSidebarRequest, showToast, fetchStatus]
|
||||
);
|
||||
|
||||
const handleBrowseFulfilDownload = useCallback(
|
||||
async (book: Book, release: Release, releaseContentType: ContentType) => {
|
||||
if (!fulfillingRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fulfilSidebarRequest(
|
||||
fulfillingRequest.requestId,
|
||||
buildReleaseDataFromMetadataRelease(book, release, toContentType(releaseContentType))
|
||||
);
|
||||
showToast(`Request approved: ${book.title || 'Untitled'}`, 'success');
|
||||
setFulfillingRequest(null);
|
||||
await fetchStatus();
|
||||
} catch (error) {
|
||||
console.error('Browse fulfil failed:', error);
|
||||
showToast(getErrorMessage(error, 'Failed to fulfil request'), 'error');
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[fulfillingRequest, fulfilSidebarRequest, showToast, fetchStatus]
|
||||
);
|
||||
|
||||
const getDirectActionButtonState = useCallback(
|
||||
(bookId: string): ButtonStateInfo => {
|
||||
const baseState = getButtonState(bookId);
|
||||
const mode = getDirectPolicyMode();
|
||||
return applyDirectPolicyModeToButtonState(baseState, mode);
|
||||
},
|
||||
[getButtonState, getDirectPolicyMode]
|
||||
);
|
||||
|
||||
const getUniversalActionButtonState = useCallback(
|
||||
(bookId: string): ButtonStateInfo => {
|
||||
const baseState = getUniversalButtonState(bookId);
|
||||
const mode = getUniversalDefaultPolicyMode();
|
||||
return applyUniversalPolicyModeToButtonState(baseState, mode);
|
||||
},
|
||||
[getUniversalButtonState, getUniversalDefaultPolicyMode]
|
||||
);
|
||||
|
||||
const bookLanguages = config?.book_languages || DEFAULT_LANGUAGES;
|
||||
const supportedFormats = config?.supported_formats || DEFAULT_SUPPORTED_FORMATS;
|
||||
const defaultLanguageCodes =
|
||||
@@ -556,55 +1098,89 @@ function App() {
|
||||
defaultLanguage: defaultLanguageCodes,
|
||||
searchMode,
|
||||
});
|
||||
handleSearch(query, config, { ...searchFieldValues, series: seriesName });
|
||||
}, [setSearchInput, clearTracking, searchFieldValues, advancedFilters, setAdvancedFilters, bookLanguages, defaultLanguageCodes, searchMode, config, handleSearch]);
|
||||
runSearchWithPolicyRefresh(query, { ...searchFieldValues, series: seriesName });
|
||||
}, [setSearchInput, clearTracking, searchFieldValues, advancedFilters, setAdvancedFilters, bookLanguages, defaultLanguageCodes, searchMode, runSearchWithPolicyRefresh]);
|
||||
|
||||
const isBrowseFulfilMode = fulfillingRequest !== null;
|
||||
const activeReleaseBook = fulfillingRequest?.book ?? releaseBook;
|
||||
const activeReleaseContentType = fulfillingRequest?.contentType ?? contentType;
|
||||
const usePinnedMainScrollContainer = sidebarPinnedOpen;
|
||||
|
||||
const handleReleaseModalClose = useCallback(() => {
|
||||
if (isBrowseFulfilMode) {
|
||||
setFulfillingRequest(null);
|
||||
return;
|
||||
}
|
||||
setReleaseBook(null);
|
||||
}, [isBrowseFulfilMode]);
|
||||
|
||||
const mainAppContent = (
|
||||
<SearchModeProvider searchMode={searchMode}>
|
||||
<Header
|
||||
calibreWebUrl={config?.calibre_web_url || ''}
|
||||
audiobookLibraryUrl={config?.audiobook_library_url || ''}
|
||||
debug={config?.debug || false}
|
||||
logoUrl={logoUrl}
|
||||
showSearch={!isInitialState}
|
||||
searchInput={searchInput}
|
||||
onSearchChange={setSearchInput}
|
||||
onDownloadsClick={() => setDownloadsSidebarOpen(true)}
|
||||
onSettingsClick={() => {
|
||||
if (config?.settings_enabled) {
|
||||
setSettingsOpen(true);
|
||||
} else {
|
||||
setConfigBannerOpen(true);
|
||||
}
|
||||
}}
|
||||
isAdmin={isAdmin}
|
||||
username={username}
|
||||
displayName={displayName}
|
||||
statusCounts={statusCounts}
|
||||
onLogoClick={() => handleResetSearch(config)}
|
||||
authRequired={authRequired}
|
||||
isAuthenticated={isAuthenticated}
|
||||
onLogout={handleLogoutWithCleanup}
|
||||
onSearch={() => {
|
||||
const query = buildSearchQuery({
|
||||
searchInput,
|
||||
showAdvanced,
|
||||
advancedFilters,
|
||||
bookLanguages,
|
||||
defaultLanguage: defaultLanguageCodes,
|
||||
searchMode,
|
||||
});
|
||||
handleSearch(query, config, searchFieldValues);
|
||||
}}
|
||||
onAdvancedToggle={() => setShowAdvanced(!showAdvanced)}
|
||||
isLoading={isSearching}
|
||||
onShowToast={showToast}
|
||||
onRemoveToast={removeToast}
|
||||
contentType={contentType}
|
||||
onContentTypeChange={setContentType}
|
||||
/>
|
||||
<div ref={headerRef} className="fixed top-0 left-0 right-0 z-40">
|
||||
<Header
|
||||
calibreWebUrl={config?.calibre_web_url || ''}
|
||||
audiobookLibraryUrl={config?.audiobook_library_url || ''}
|
||||
debug={config?.debug || false}
|
||||
logoUrl={logoUrl}
|
||||
showSearch={!isInitialState}
|
||||
searchInput={searchInput}
|
||||
onSearchChange={setSearchInput}
|
||||
onDownloadsClick={() => setDownloadsSidebarOpen((prev) => !prev)}
|
||||
onSettingsClick={() => {
|
||||
if (config?.settings_enabled) {
|
||||
setSettingsOpen(true);
|
||||
} else {
|
||||
setConfigBannerOpen(true);
|
||||
}
|
||||
}}
|
||||
isAdmin={requestRoleIsAdmin}
|
||||
canAccessSettings={authCanAccessSettings}
|
||||
username={username}
|
||||
displayName={displayName}
|
||||
statusCounts={statusCounts}
|
||||
onLogoClick={() => handleResetSearch(config)}
|
||||
authRequired={authRequired}
|
||||
isAuthenticated={isAuthenticated}
|
||||
onLogout={handleLogoutWithCleanup}
|
||||
onSearch={() => {
|
||||
const query = buildSearchQuery({
|
||||
searchInput,
|
||||
showAdvanced,
|
||||
advancedFilters,
|
||||
bookLanguages,
|
||||
defaultLanguage: defaultLanguageCodes,
|
||||
searchMode,
|
||||
});
|
||||
runSearchWithPolicyRefresh(query);
|
||||
}}
|
||||
onAdvancedToggle={() => setShowAdvanced(!showAdvanced)}
|
||||
isLoading={isSearching}
|
||||
onShowToast={showToast}
|
||||
onRemoveToast={removeToast}
|
||||
contentType={contentType}
|
||||
onContentTypeChange={setContentType}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdvancedFilters
|
||||
<div
|
||||
className={`flex flex-col${
|
||||
usePinnedMainScrollContainer
|
||||
? ' min-h-0 overflow-y-auto overscroll-y-contain'
|
||||
: ' flex-1'
|
||||
}`}
|
||||
style={
|
||||
usePinnedMainScrollContainer
|
||||
? {
|
||||
position: 'fixed',
|
||||
top: `${headerHeight}px`,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: '25rem',
|
||||
}
|
||||
: { paddingTop: `${headerHeight}px` }
|
||||
}
|
||||
>
|
||||
<AdvancedFilters
|
||||
visible={showAdvanced && !isInitialState}
|
||||
bookLanguages={bookLanguages}
|
||||
defaultLanguage={defaultLanguageCodes}
|
||||
@@ -623,13 +1199,20 @@ function App() {
|
||||
defaultLanguage: defaultLanguageCodes,
|
||||
searchMode,
|
||||
});
|
||||
handleSearch(query, config, searchFieldValues);
|
||||
runSearchWithPolicyRefresh(query);
|
||||
}}
|
||||
/>
|
||||
|
||||
<main className="relative w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-6">
|
||||
<main
|
||||
className="relative w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-6"
|
||||
style={
|
||||
usePinnedMainScrollContainer
|
||||
? { display: 'block', flex: '0 0 auto', minHeight: 0 }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SearchSection
|
||||
onSearch={(query) => handleSearch(query, config, searchFieldValues)}
|
||||
onSearch={(query) => runSearchWithPolicyRefresh(query)}
|
||||
isLoading={isSearching}
|
||||
isInitialState={isInitialState}
|
||||
bookLanguages={bookLanguages}
|
||||
@@ -655,8 +1238,8 @@ function App() {
|
||||
onDetails={handleShowDetails}
|
||||
onDownload={handleDownload}
|
||||
onGetReleases={handleGetReleases}
|
||||
getButtonState={getButtonState}
|
||||
getUniversalButtonState={getUniversalButtonState}
|
||||
getButtonState={getDirectActionButtonState}
|
||||
getUniversalButtonState={getUniversalActionButtonState}
|
||||
sortValue={advancedFilters.sort}
|
||||
onSortChange={(value) => handleSortChange(value, config)}
|
||||
metadataSortOptions={config?.metadata_sort_options}
|
||||
@@ -673,43 +1256,70 @@ function App() {
|
||||
onDownload={handleDownload}
|
||||
onFindDownloads={handleFindDownloads}
|
||||
onSearchSeries={handleSearchSeries}
|
||||
buttonState={getButtonState(selectedBook.id)}
|
||||
buttonState={getDirectActionButtonState(selectedBook.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{releaseBook && (
|
||||
{activeReleaseBook && (
|
||||
<ReleaseModal
|
||||
book={releaseBook}
|
||||
onClose={() => setReleaseBook(null)}
|
||||
onDownload={handleReleaseDownload}
|
||||
book={activeReleaseBook}
|
||||
onClose={handleReleaseModalClose}
|
||||
onDownload={isBrowseFulfilMode ? handleBrowseFulfilDownload : handleReleaseDownload}
|
||||
onRequestRelease={isBrowseFulfilMode ? undefined : handleReleaseRequest}
|
||||
getPolicyModeForSource={isBrowseFulfilMode ? () => 'download' : (source, ct) => getSourceMode(source, ct)}
|
||||
onPolicyRefresh={() => refreshRequestPolicy({ force: true })}
|
||||
supportedFormats={supportedFormats}
|
||||
supportedAudiobookFormats={config?.supported_audiobook_formats || []}
|
||||
contentType={contentType}
|
||||
contentType={activeReleaseContentType}
|
||||
defaultLanguages={defaultLanguageCodes}
|
||||
bookLanguages={bookLanguages}
|
||||
currentStatus={currentStatus}
|
||||
defaultReleaseSource={config?.default_release_source}
|
||||
onSearchSeries={handleSearchSeries}
|
||||
onSearchSeries={isBrowseFulfilMode ? undefined : handleSearchSeries}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pendingRequestPayload && (
|
||||
<RequestConfirmationModal
|
||||
payload={pendingRequestPayload}
|
||||
allowNotes={allowRequestNotes}
|
||||
onConfirm={handleConfirmRequest}
|
||||
onClose={() => setPendingRequestPayload(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
</main>
|
||||
|
||||
<Footer
|
||||
buildVersion={config?.build_version}
|
||||
releaseVersion={config?.release_version}
|
||||
debug={config?.debug}
|
||||
/>
|
||||
<ToastContainer toasts={toasts} />
|
||||
<div className={usePinnedMainScrollContainer ? 'mt-auto' : undefined}>
|
||||
<Footer
|
||||
buildVersion={config?.build_version}
|
||||
releaseVersion={config?.release_version}
|
||||
debug={config?.debug}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DownloadsSidebar
|
||||
<ActivitySidebar
|
||||
isOpen={downloadsSidebarOpen}
|
||||
onClose={() => setDownloadsSidebarOpen(false)}
|
||||
status={currentStatus}
|
||||
isAdmin={requestRoleIsAdmin}
|
||||
onClearCompleted={handleClearCompleted}
|
||||
onCancel={handleCancel}
|
||||
requestItems={requestItems}
|
||||
pendingRequestCount={pendingRequestCount}
|
||||
showRequestsTab={showRequestsTab}
|
||||
isRequestsLoading={isRequestsLoading}
|
||||
onRequestCancel={showRequestsTab ? handleRequestCancel : undefined}
|
||||
onRequestApprove={requestRoleIsAdmin ? handleRequestApprove : undefined}
|
||||
onRequestReject={requestRoleIsAdmin ? handleRequestReject : undefined}
|
||||
onRequestDismiss={showRequestsTab ? handleRequestDismiss : undefined}
|
||||
onPinnedOpenChange={setSidebarPinnedOpen}
|
||||
pinnedTopOffset={headerHeight}
|
||||
/>
|
||||
|
||||
<ToastContainer toasts={toasts} />
|
||||
|
||||
<SettingsModal
|
||||
isOpen={settingsOpen}
|
||||
authMode={authMode}
|
||||
|
||||
@@ -63,8 +63,9 @@ export const BookDownloadButton = ({
|
||||
|
||||
const isCompleted = buttonState.state === 'complete';
|
||||
const hasError = buttonState.state === 'error';
|
||||
const isBlocked = buttonState.state === 'blocked';
|
||||
const isInProgress = ['queued', 'resolving', 'locating', 'downloading'].includes(buttonState.state);
|
||||
const isDisabled = buttonState.state !== 'download' || isQueuing || isCompleted;
|
||||
const isDisabled = buttonState.state !== 'download' || isQueuing || isCompleted || isBlocked;
|
||||
const displayText = isQueuing ? 'Queuing...' : buttonState.text;
|
||||
const showCircularProgress = buttonState.state === 'downloading' && buttonState.progress !== undefined;
|
||||
const showSpinner = (isInProgress && !showCircularProgress) || isQueuing;
|
||||
@@ -74,6 +75,8 @@ export const BookDownloadButton = ({
|
||||
? 'bg-green-600 cursor-not-allowed'
|
||||
: hasError
|
||||
? 'bg-red-600 cursor-not-allowed opacity-75'
|
||||
: isBlocked
|
||||
? 'bg-gray-500 cursor-not-allowed opacity-70'
|
||||
: isInProgress
|
||||
? 'bg-gray-500 cursor-not-allowed opacity-75'
|
||||
: 'bg-sky-700 hover:bg-sky-800';
|
||||
@@ -83,6 +86,8 @@ export const BookDownloadButton = ({
|
||||
? 'bg-green-600 text-white cursor-not-allowed'
|
||||
: hasError
|
||||
? 'bg-red-600 text-white cursor-not-allowed opacity-75'
|
||||
: isBlocked
|
||||
? 'text-gray-400 dark:text-gray-500 cursor-not-allowed opacity-70'
|
||||
: isInProgress
|
||||
? 'bg-gray-500 text-white cursor-not-allowed opacity-75'
|
||||
: 'text-gray-600 dark:text-gray-200 hover-action';
|
||||
@@ -105,7 +110,9 @@ export const BookDownloadButton = ({
|
||||
await onDownload();
|
||||
} catch (error) {
|
||||
setIsQueuing(false);
|
||||
return;
|
||||
}
|
||||
setIsQueuing(false);
|
||||
};
|
||||
|
||||
const renderStatusIcon = () => {
|
||||
@@ -149,6 +156,26 @@ export const BookDownloadButton = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (isBlocked) {
|
||||
if (variant === 'icon' && iconSizes) {
|
||||
return (
|
||||
<>
|
||||
<svg className={`${iconSizes.mobile} sm:hidden`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M16.5 10.5V7.875a4.125 4.125 0 1 0-8.25 0V10.5m-.75 0h9a2.25 2.25 0 0 1 2.25 2.25v6A2.25 2.25 0 0 1 16.5 21h-9a2.25 2.25 0 0 1-2.25-2.25v-6a2.25 2.25 0 0 1 2.25-2.25Z" />
|
||||
</svg>
|
||||
<svg className={`${iconSizes.desktop} hidden sm:block`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M16.5 10.5V7.875a4.125 4.125 0 1 0-8.25 0V10.5m-.75 0h9a2.25 2.25 0 0 1 2.25 2.25v6A2.25 2.25 0 0 1 16.5 21h-9a2.25 2.25 0 0 1-2.25-2.25v-6a2.25 2.25 0 0 1 2.25-2.25Z" />
|
||||
</svg>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg className={primaryIconSizes[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16.5 10.5V7.875a4.125 4.125 0 1 0-8.25 0V10.5m-.75 0h9a2.25 2.25 0 0 1 2.25 2.25v6A2.25 2.25 0 0 1 16.5 21h-9a2.25 2.25 0 0 1-2.25-2.25v-6a2.25 2.25 0 0 1 2.25-2.25Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (showCircularProgress) {
|
||||
if (variant === 'icon') {
|
||||
const progressSize = iconVariantProgressSizes[size].mobile;
|
||||
|
||||
@@ -56,12 +56,13 @@ export const BookGetButton = ({
|
||||
// Determine states based on buttonState
|
||||
const isCompleted = buttonState?.state === 'complete';
|
||||
const hasError = buttonState?.state === 'error';
|
||||
const isBlocked = buttonState?.state === 'blocked';
|
||||
const isInProgress = buttonState && ['queued', 'resolving', 'locating', 'downloading'].includes(buttonState.state);
|
||||
const showCircularProgress = buttonState?.state === 'downloading' && buttonState.progress !== undefined;
|
||||
const showSpinner = (isInProgress && !showCircularProgress) || isLoading;
|
||||
|
||||
// Disable button while loading metadata
|
||||
const isDisabled = isLoading;
|
||||
const isDisabled = isLoading || isBlocked;
|
||||
|
||||
// Determine button styling based on state
|
||||
const getButtonClasses = () => {
|
||||
@@ -75,6 +76,11 @@ export const BookGetButton = ({
|
||||
? 'bg-red-600 text-white opacity-75'
|
||||
: 'bg-red-600 hover:bg-red-700';
|
||||
}
|
||||
if (isBlocked) {
|
||||
return isIconVariant
|
||||
? 'text-gray-400 dark:text-gray-500 cursor-not-allowed opacity-70'
|
||||
: 'bg-gray-500 opacity-75 cursor-not-allowed';
|
||||
}
|
||||
if (isLoading) {
|
||||
// Show loading state (fetching metadata)
|
||||
return isIconVariant
|
||||
@@ -100,6 +106,7 @@ export const BookGetButton = ({
|
||||
|
||||
// Determine display text
|
||||
const getDisplayText = () => {
|
||||
if (isBlocked) return buttonState?.text || 'Unavailable';
|
||||
if (isCompleted) return 'Downloaded';
|
||||
if (hasError) return 'Failed';
|
||||
if (isLoading) return 'Loading';
|
||||
@@ -107,6 +114,7 @@ export const BookGetButton = ({
|
||||
if (buttonState?.state === 'locating') return 'Locating files';
|
||||
if (buttonState?.state === 'resolving') return 'Resolving';
|
||||
if (buttonState?.state === 'queued') return 'Queued';
|
||||
if (buttonState?.state === 'download' && buttonState.text) return buttonState.text;
|
||||
return 'Get';
|
||||
};
|
||||
|
||||
@@ -128,6 +136,14 @@ export const BookGetButton = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (isBlocked) {
|
||||
return (
|
||||
<svg className={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16.5 10.5V7.875a4.125 4.125 0 1 0-8.25 0V10.5m-.75 0h9a2.25 2.25 0 0 1 2.25 2.25v6A2.25 2.25 0 0 1 16.5 21h-9a2.25 2.25 0 0 1-2.25-2.25v-6a2.25 2.25 0 0 1 2.25-2.25Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (showCircularProgress) {
|
||||
const progressSize = isIconVariant ? (size === 'sm' ? 16 : 20) : (size === 'sm' ? 12 : 16);
|
||||
return <CircularProgress progress={buttonState?.progress} size={progressSize} />;
|
||||
|
||||
@@ -342,6 +342,8 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
|
||||
className={`ml-auto rounded-full px-6 py-2.5 text-sm font-medium text-white transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
isMetadata
|
||||
? 'bg-emerald-600 hover:bg-emerald-700 focus:ring-emerald-500'
|
||||
: buttonState.state === 'blocked'
|
||||
? 'bg-gray-500 focus:ring-gray-400'
|
||||
: 'bg-sky-700 hover:bg-sky-800 focus:ring-sky-500'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -20,19 +20,6 @@ const STATUS_STYLES: Record<string, { bg: string; text: string; label: string; w
|
||||
cancelled: { bg: 'bg-gray-500/20', text: 'text-gray-700 dark:text-gray-300', label: 'Cancelled', waveColor: '' },
|
||||
};
|
||||
|
||||
// Add keyframe animation for wave effect
|
||||
const styleSheet = document.createElement('style');
|
||||
styleSheet.textContent = `
|
||||
@keyframes wave {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
`;
|
||||
if (!document.head.querySelector('style[data-wave-animation]')) {
|
||||
styleSheet.setAttribute('data-wave-animation', 'true');
|
||||
document.head.appendChild(styleSheet);
|
||||
}
|
||||
|
||||
// Book thumbnail component with fallback
|
||||
const BookThumbnail = ({ preview, title }: { preview?: string; title?: string }) => {
|
||||
if (!preview) {
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import { useState, useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
|
||||
import { SearchBar, SearchBarHandle } from './SearchBar';
|
||||
import { ContentType } from '../types';
|
||||
import { ActivityStatusCounts, getActivityBadgeState } from '../utils/activityBadge';
|
||||
import { withBasePath } from '../utils/basePath';
|
||||
|
||||
export interface HeaderHandle {
|
||||
submitSearch: () => void;
|
||||
}
|
||||
|
||||
interface StatusCounts {
|
||||
ongoing: number;
|
||||
completed: number;
|
||||
errored: number;
|
||||
}
|
||||
|
||||
interface HeaderProps {
|
||||
calibreWebUrl?: string;
|
||||
audiobookLibraryUrl?: string;
|
||||
@@ -27,7 +22,8 @@ interface HeaderProps {
|
||||
onDownloadsClick?: () => void;
|
||||
onSettingsClick?: () => void;
|
||||
isAdmin?: boolean;
|
||||
statusCounts?: StatusCounts;
|
||||
canAccessSettings?: boolean;
|
||||
statusCounts?: ActivityStatusCounts;
|
||||
onLogoClick?: () => void;
|
||||
authRequired?: boolean;
|
||||
isAuthenticated?: boolean;
|
||||
@@ -54,7 +50,8 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
onDownloadsClick,
|
||||
onSettingsClick,
|
||||
isAdmin = false,
|
||||
statusCounts = { ongoing: 0, completed: 0, errored: 0 },
|
||||
canAccessSettings,
|
||||
statusCounts = { ongoing: 0, completed: 0, errored: 0, pendingRequests: 0 },
|
||||
onLogoClick,
|
||||
authRequired = false,
|
||||
isAuthenticated = false,
|
||||
@@ -66,6 +63,8 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
contentType = 'ebook',
|
||||
onContentTypeChange,
|
||||
}, ref) => {
|
||||
const activityBadge = getActivityBadgeState(statusCounts, isAdmin);
|
||||
const settingsEnabled = canAccessSettings ?? isAdmin;
|
||||
const searchBarRef = useRef<SearchBarHandle>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
@@ -206,13 +205,13 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Downloads Button */}
|
||||
{/* Activity Button */}
|
||||
{onDownloadsClick && (
|
||||
<button
|
||||
onClick={onDownloadsClick}
|
||||
className="relative flex items-center gap-2 px-3 py-2 rounded-full hover-action transition-all duration-200 text-gray-900 dark:text-gray-100"
|
||||
aria-label="View downloads"
|
||||
title="Downloads"
|
||||
aria-label="View activity"
|
||||
title="Activity"
|
||||
>
|
||||
<div className="relative">
|
||||
<svg
|
||||
@@ -229,23 +228,16 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
|
||||
/>
|
||||
</svg>
|
||||
{/* Show badge with appropriate color based on status */}
|
||||
{(statusCounts.ongoing > 0 || statusCounts.completed > 0 || statusCounts.errored > 0) && (
|
||||
<span
|
||||
className={`absolute -top-1 -right-1 text-white text-[0.55rem] font-bold rounded-full w-3.5 h-3.5 flex items-center justify-center ${
|
||||
statusCounts.errored > 0
|
||||
? 'bg-red-500'
|
||||
: statusCounts.ongoing > 0
|
||||
? 'bg-blue-500'
|
||||
: 'bg-green-500'
|
||||
}`}
|
||||
title={`${statusCounts.ongoing} ongoing, ${statusCounts.completed} completed, ${statusCounts.errored} failed`}
|
||||
{activityBadge && (
|
||||
<span
|
||||
className={`absolute -top-1 -right-1 text-white text-[0.55rem] font-bold rounded-full w-3.5 h-3.5 flex items-center justify-center ${activityBadge.colorClass}`}
|
||||
title={activityBadge.title}
|
||||
>
|
||||
{statusCounts.ongoing + statusCounts.completed + statusCounts.errored}
|
||||
{activityBadge.total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="hidden sm:inline text-sm font-medium">Downloads</span>
|
||||
<span className="hidden sm:inline text-sm font-medium">Activity</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -317,13 +309,13 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
{onSettingsClick && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={isAdmin ? () => {
|
||||
onClick={settingsEnabled ? () => {
|
||||
closeDropdown();
|
||||
onSettingsClick();
|
||||
} : undefined}
|
||||
disabled={!isAdmin}
|
||||
disabled={!settingsEnabled}
|
||||
className={`w-full text-left px-4 py-2 transition-colors flex items-center gap-3 ${
|
||||
isAdmin ? 'hover-surface' : 'opacity-40 cursor-not-allowed'
|
||||
settingsEnabled ? 'hover-surface' : 'opacity-40 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
|
||||
@@ -61,6 +61,10 @@ function isFieldVisible(
|
||||
field: SettingsField,
|
||||
values: Record<string, unknown>
|
||||
): boolean {
|
||||
if ('hiddenInUi' in field && field.hiddenInUi) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const showWhen = field.showWhen;
|
||||
if (!showWhen) return true;
|
||||
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
||||
import { Book, Release, ReleaseSource, ReleasesResponse, Language, StatusData, ButtonStateInfo, ColumnSchema, ReleaseColumnConfig, LeadingCellConfig, SearchStatusData, ContentType } from '../types';
|
||||
import {
|
||||
Book,
|
||||
Release,
|
||||
ReleaseSource,
|
||||
ReleasesResponse,
|
||||
Language,
|
||||
StatusData,
|
||||
ButtonStateInfo,
|
||||
ColumnSchema,
|
||||
ReleaseColumnConfig,
|
||||
LeadingCellConfig,
|
||||
SearchStatusData,
|
||||
ContentType,
|
||||
RequestPolicyMode,
|
||||
} from '../types';
|
||||
import { getReleases, getReleaseSources } from '../services/api';
|
||||
import { useSocket } from '../contexts/SocketContext';
|
||||
import { Dropdown } from './Dropdown';
|
||||
@@ -178,6 +192,9 @@ interface ReleaseModalProps {
|
||||
book: Book | null;
|
||||
onClose: () => void;
|
||||
onDownload: (book: Book, release: Release, contentType: ContentType) => Promise<void>;
|
||||
onRequestRelease?: (book: Book, release: Release, contentType: ContentType) => Promise<void>;
|
||||
getPolicyModeForSource?: (source: string, contentType: ContentType) => RequestPolicyMode;
|
||||
onPolicyRefresh?: () => Promise<unknown>;
|
||||
supportedFormats: string[];
|
||||
supportedAudiobookFormats?: string[]; // Audiobook formats (m4b, mp3)
|
||||
contentType: ContentType; // 'ebook' or 'audiobook'
|
||||
@@ -391,7 +408,7 @@ const ReleaseRow = ({
|
||||
onDownload={onDownload}
|
||||
variant="icon"
|
||||
size="sm"
|
||||
ariaLabel={`Download ${release.title}`}
|
||||
ariaLabel={`${buttonState.text} ${release.title}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -467,7 +484,7 @@ const ReleaseRow = ({
|
||||
onDownload={onDownload}
|
||||
variant="icon"
|
||||
size="sm"
|
||||
ariaLabel={`Download ${release.title}`}
|
||||
ariaLabel={`${buttonState.text} ${release.title}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -603,6 +620,9 @@ export const ReleaseModal = ({
|
||||
book,
|
||||
onClose,
|
||||
onDownload,
|
||||
onRequestRelease,
|
||||
getPolicyModeForSource,
|
||||
onPolicyRefresh,
|
||||
supportedFormats,
|
||||
supportedAudiobookFormats = [],
|
||||
contentType = 'ebook',
|
||||
@@ -664,6 +684,11 @@ export const ReleaseModal = ({
|
||||
const [descriptionOverflows, setDescriptionOverflows] = useState(false);
|
||||
const descriptionRef = useRef<HTMLParagraphElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!book || !onPolicyRefresh) return;
|
||||
void onPolicyRefresh();
|
||||
}, [book, onPolicyRefresh]);
|
||||
|
||||
// Close handler with animation
|
||||
const handleClose = useCallback(() => {
|
||||
setIsClosing(true);
|
||||
@@ -1203,9 +1228,20 @@ export const ReleaseModal = ({
|
||||
return { starField, ratingsField, usersField, pagesField };
|
||||
}, [book?.display_fields]);
|
||||
|
||||
// Get button state for a release based on its source_id
|
||||
const getReleaseActionMode = useCallback(
|
||||
(release: Release): RequestPolicyMode => {
|
||||
if (!getPolicyModeForSource) {
|
||||
return 'download';
|
||||
}
|
||||
return getPolicyModeForSource(release.source, contentType);
|
||||
},
|
||||
[getPolicyModeForSource, contentType]
|
||||
);
|
||||
|
||||
// Get button state for a release row (queue state + policy mode).
|
||||
const getButtonState = useCallback(
|
||||
(releaseId: string): ButtonStateInfo => {
|
||||
(release: Release): ButtonStateInfo => {
|
||||
const releaseId = release.source_id;
|
||||
// Check error first
|
||||
if (currentStatus.error && currentStatus.error[releaseId]) {
|
||||
return { text: 'Failed', state: 'error' };
|
||||
@@ -1232,21 +1268,43 @@ export const ReleaseModal = ({
|
||||
if (currentStatus.queued && currentStatus.queued[releaseId]) {
|
||||
return { text: 'Queued', state: 'queued' };
|
||||
}
|
||||
|
||||
const mode = getReleaseActionMode(release);
|
||||
if (mode === 'request_release') {
|
||||
return { text: 'Request', state: 'download' };
|
||||
}
|
||||
if (mode === 'blocked' || mode === 'request_book') {
|
||||
return { text: 'Unavailable', state: 'blocked' };
|
||||
}
|
||||
return { text: 'Download', state: 'download' };
|
||||
},
|
||||
[currentStatus]
|
||||
[currentStatus, getReleaseActionMode]
|
||||
);
|
||||
|
||||
// Handle download - close modal once download is successfully queued
|
||||
const handleDownload = useCallback(
|
||||
// Handle row action based on resolved policy mode.
|
||||
const handleReleaseAction = useCallback(
|
||||
async (release: Release): Promise<void> => {
|
||||
if (book) {
|
||||
await onDownload(book, release, contentType);
|
||||
// Close modal after successful queue
|
||||
handleClose();
|
||||
if (!book) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mode = getReleaseActionMode(release);
|
||||
if (mode === 'download') {
|
||||
await onDownload(book, release, contentType);
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
if (mode === 'request_release') {
|
||||
if (onRequestRelease) {
|
||||
await onRequestRelease(book, release, contentType);
|
||||
handleClose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// blocked / request_book — should not be reachable (button is disabled),
|
||||
// but guard defensively.
|
||||
},
|
||||
[book, onDownload, contentType, handleClose]
|
||||
[book, getReleaseActionMode, onDownload, onRequestRelease, contentType, handleClose]
|
||||
);
|
||||
|
||||
if (!book && !isClosing) return null;
|
||||
@@ -1856,8 +1914,8 @@ export const ReleaseModal = ({
|
||||
key={`${release.source}-${release.source_id}`}
|
||||
release={release}
|
||||
index={index}
|
||||
onDownload={() => handleDownload(release)}
|
||||
buttonState={getButtonState(release.source_id)}
|
||||
onDownload={() => handleReleaseAction(release)}
|
||||
buttonState={getButtonState(release)}
|
||||
columns={columnConfig.columns}
|
||||
gridTemplate={columnConfig.grid_template}
|
||||
leadingCell={columnConfig.leading_cell}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { CreateRequestPayload } from '../types';
|
||||
import { getMetadataBookInfo } from '../services/api';
|
||||
import {
|
||||
applyRequestNoteToPayload,
|
||||
buildRequestConfirmationPreview,
|
||||
enrichPreviewFromBook,
|
||||
MAX_REQUEST_NOTE_LENGTH,
|
||||
RequestConfirmationPreview,
|
||||
truncateRequestNote,
|
||||
} from '../utils/requestConfirmation';
|
||||
|
||||
interface RequestConfirmationModalProps {
|
||||
payload: CreateRequestPayload | null;
|
||||
allowNotes: boolean;
|
||||
onConfirm: (payload: CreateRequestPayload) => Promise<boolean>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const RequestConfirmationModal = ({
|
||||
payload,
|
||||
allowNotes,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: RequestConfirmationModalProps) => {
|
||||
const [note, setNote] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
setIsClosing(false);
|
||||
}, 150);
|
||||
}, [isSubmitting, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (payload) {
|
||||
setNote('');
|
||||
setIsSubmitting(false);
|
||||
setIsClosing(false);
|
||||
}
|
||||
}, [payload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!payload) return;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, [payload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!payload) return;
|
||||
const onEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onEscape);
|
||||
return () => document.removeEventListener('keydown', onEscape);
|
||||
}, [payload, handleClose]);
|
||||
|
||||
const basePreview = useMemo(() => {
|
||||
return payload ? buildRequestConfirmationPreview(payload) : null;
|
||||
}, [payload]);
|
||||
|
||||
const [enriched, setEnriched] = useState<RequestConfirmationPreview | null>(null);
|
||||
const enrichRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
setEnriched(null);
|
||||
if (!payload) return;
|
||||
|
||||
const bookData = payload.book_data || {};
|
||||
const provider = bookData.provider;
|
||||
const providerId = bookData.provider_id;
|
||||
|
||||
// Only fetch for metadata providers, and skip if series info is already present
|
||||
if (
|
||||
typeof provider !== 'string' || !provider ||
|
||||
typeof providerId !== 'string' || !providerId ||
|
||||
provider === 'direct_download' ||
|
||||
bookData.series_name
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = ++enrichRef.current;
|
||||
getMetadataBookInfo(provider, providerId)
|
||||
.then((book) => {
|
||||
if (id !== enrichRef.current) return;
|
||||
if (book.series_name) {
|
||||
setEnriched((prev) => enrichPreviewFromBook(prev ?? basePreview!, book));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Enrichment is best-effort; ignore failures
|
||||
});
|
||||
}, [payload, basePreview]);
|
||||
|
||||
const preview = enriched ?? basePreview;
|
||||
|
||||
if (!payload && !isClosing) return null;
|
||||
if (!payload) return null;
|
||||
if (!preview) return null;
|
||||
|
||||
const titleId = 'request-confirmation-modal-title';
|
||||
const confirmDisabled = isSubmitting || (allowNotes && note.length > MAX_REQUEST_NOTE_LENGTH);
|
||||
|
||||
const submit = async () => {
|
||||
if (confirmDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const nextPayload = applyRequestNoteToPayload(payload, note, allowNotes);
|
||||
const success = await onConfirm(nextPayload);
|
||||
if (!success) {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
} catch {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`relative w-full max-w-xl rounded-xl border border-[var(--border-muted)] shadow-2xl ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
|
||||
style={{ background: 'var(--bg)' }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<header className="flex items-center justify-between border-b border-[var(--border-muted)] px-6 py-4">
|
||||
<h3 id={titleId} className="text-lg font-semibold">
|
||||
Request Book
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="p-1.5 rounded-lg hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label="Close request confirmation"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
<div className="rounded-xl border border-[var(--border-muted)] bg-[var(--bg-soft)] px-4 py-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="w-16 h-24 flex-shrink-0 rounded-lg overflow-hidden border border-[var(--border-muted)] bg-[var(--bg)]">
|
||||
{preview.preview ? (
|
||||
<img
|
||||
src={preview.preview}
|
||||
alt={`${preview.title} cover`}
|
||||
className="w-full h-full object-cover object-top"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-[10px] opacity-60">
|
||||
No cover
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold leading-snug">{preview.title}</p>
|
||||
<p className="text-sm opacity-80 mt-1">{preview.author}</p>
|
||||
{(preview.year || preview.seriesLine) && (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 mt-1.5">
|
||||
{preview.year && (
|
||||
<span className="text-xs opacity-60">{preview.year}</span>
|
||||
)}
|
||||
{preview.year && preview.seriesLine && (
|
||||
<span className="text-xs opacity-40">·</span>
|
||||
)}
|
||||
{preview.seriesLine && (
|
||||
<span className="text-xs opacity-60">{preview.seriesLine}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{preview.releaseLine && (
|
||||
<p className="text-xs opacity-60 mt-1.5">{preview.releaseLine}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{allowNotes && (
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="request-note" className="text-sm font-medium">
|
||||
Note (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="request-note"
|
||||
value={note}
|
||||
onChange={(event) => setNote(truncateRequestNote(event.target.value))}
|
||||
maxLength={MAX_REQUEST_NOTE_LENGTH}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)] bg-[var(--bg)] text-sm resize-y min-h-[96px] focus:outline-none focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500"
|
||||
placeholder="Add context for admins reviewing this request..."
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<p className="text-xs opacity-60 text-right">
|
||||
{note.length}/{MAX_REQUEST_NOTE_LENGTH}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="flex items-center justify-end gap-3 border-t border-[var(--border-muted)] px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--bg-soft)] border border-[var(--border-muted)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={confirmDisabled}
|
||||
className="px-5 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed inline-flex items-center gap-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Requesting...
|
||||
</>
|
||||
) : (
|
||||
'Request'
|
||||
)}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,378 @@
|
||||
import { ReactNode, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { RequestRecord } from '../../types';
|
||||
import { withBasePath } from '../../utils/basePath';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { ActivityItem } from './activityTypes';
|
||||
import { ActivityCardAction, buildActivityCardModel } from './activityCardModel';
|
||||
import {
|
||||
STATUS_BADGE_STYLES,
|
||||
STATUS_TOOLTIP_CLASSES,
|
||||
getProgressConfig,
|
||||
} from './activityStyles';
|
||||
|
||||
interface ActivityCardProps {
|
||||
item: ActivityItem;
|
||||
isAdmin: boolean;
|
||||
onDownloadCancel?: (bookId: string) => void;
|
||||
onRequestCancel?: (requestId: number) => void;
|
||||
onRequestApprove?: (requestId: number, record: RequestRecord) => void;
|
||||
onRequestReject?: (requestId: number) => void;
|
||||
onRequestDismiss?: (requestId: number) => void;
|
||||
}
|
||||
|
||||
const BookFallback = () => (
|
||||
<div className="w-12 h-[4.5rem] rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-[8px] font-medium text-gray-500 dark:text-gray-400">
|
||||
No Cover
|
||||
</div>
|
||||
);
|
||||
|
||||
const IconButton = ({
|
||||
title,
|
||||
className,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
className: string;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={title}
|
||||
className={`h-7 w-7 rounded-full inline-flex items-center justify-center transition-colors ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
const actionKey = (action: ActivityCardAction): string => {
|
||||
switch (action.kind) {
|
||||
case 'download-remove':
|
||||
case 'download-stop':
|
||||
case 'download-dismiss':
|
||||
return `${action.kind}-${action.bookId}`;
|
||||
case 'request-approve':
|
||||
return `${action.kind}-${action.requestId}-${action.record.id}`;
|
||||
case 'request-reject':
|
||||
case 'request-cancel':
|
||||
case 'request-dismiss':
|
||||
return `${action.kind}-${action.requestId}`;
|
||||
default:
|
||||
return 'action';
|
||||
}
|
||||
};
|
||||
|
||||
const actionUiConfig = (
|
||||
action: ActivityCardAction
|
||||
): { title: string; className: string; icon: 'cross' | 'check' | 'stop' } => {
|
||||
switch (action.kind) {
|
||||
case 'download-remove':
|
||||
return {
|
||||
title: 'Remove from queue',
|
||||
className: 'text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30',
|
||||
icon: 'cross',
|
||||
};
|
||||
case 'download-stop':
|
||||
return {
|
||||
title: 'Stop download',
|
||||
className: 'text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30',
|
||||
icon: 'stop',
|
||||
};
|
||||
case 'download-dismiss':
|
||||
return {
|
||||
title: 'Clear',
|
||||
className: 'text-gray-500 hover:text-red-600 hover:bg-red-100 dark:hover:bg-red-900/30',
|
||||
icon: 'cross',
|
||||
};
|
||||
case 'request-approve':
|
||||
return {
|
||||
title: 'Approve',
|
||||
className: 'text-green-600 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900/30',
|
||||
icon: 'check',
|
||||
};
|
||||
case 'request-reject':
|
||||
return {
|
||||
title: 'Reject',
|
||||
className: 'text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30',
|
||||
icon: 'cross',
|
||||
};
|
||||
case 'request-cancel':
|
||||
return {
|
||||
title: 'Cancel request',
|
||||
className: 'text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30',
|
||||
icon: 'cross',
|
||||
};
|
||||
case 'request-dismiss':
|
||||
return {
|
||||
title: 'Clear',
|
||||
className: 'text-gray-500 hover:text-red-600 hover:bg-red-100 dark:hover:bg-red-900/30',
|
||||
icon: 'cross',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: 'Action',
|
||||
className: 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700',
|
||||
icon: 'cross',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const ActionIcon = ({ icon }: { icon: 'cross' | 'check' | 'stop' }) => {
|
||||
if (icon === 'stop') {
|
||||
return (
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<rect x="6" y="6" width="12" height="12" rx="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (icon === 'check') {
|
||||
return (
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m5 13 4 4L19 7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const ActivityCard = ({
|
||||
item,
|
||||
isAdmin,
|
||||
onDownloadCancel,
|
||||
onRequestCancel,
|
||||
onRequestApprove,
|
||||
onRequestReject,
|
||||
onRequestDismiss,
|
||||
}: ActivityCardProps) => {
|
||||
const model = useMemo(() => buildActivityCardModel(item, isAdmin), [item, isAdmin]);
|
||||
const noteLine = model.noteLine;
|
||||
const badgeRefs = useRef<Record<string, HTMLSpanElement | null>>({});
|
||||
const [badgeOverflow, setBadgeOverflow] = useState<Record<string, boolean>>({});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const measureBadgeOverflow = () => {
|
||||
const nextOverflow: Record<string, boolean> = {};
|
||||
model.badges.forEach((badge, index) => {
|
||||
const badgeId = `${badge.key}-${index}`;
|
||||
const element = badgeRefs.current[badgeId];
|
||||
nextOverflow[badgeId] = Boolean(
|
||||
element && element.scrollWidth - element.clientWidth > 1
|
||||
);
|
||||
});
|
||||
|
||||
setBadgeOverflow((current) => {
|
||||
const currentKeys = Object.keys(current);
|
||||
const nextKeys = Object.keys(nextOverflow);
|
||||
if (
|
||||
currentKeys.length === nextKeys.length &&
|
||||
nextKeys.every((key) => current[key] === nextOverflow[key])
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
return nextOverflow;
|
||||
});
|
||||
};
|
||||
|
||||
measureBadgeOverflow();
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
window.addEventListener('resize', measureBadgeOverflow);
|
||||
return () => window.removeEventListener('resize', measureBadgeOverflow);
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(measureBadgeOverflow);
|
||||
model.badges.forEach((badge, index) => {
|
||||
const badgeId = `${badge.key}-${index}`;
|
||||
const element = badgeRefs.current[badgeId];
|
||||
if (element) {
|
||||
observer.observe(element);
|
||||
}
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [model.badges]);
|
||||
|
||||
const runAction = (action: ActivityCardAction) => {
|
||||
switch (action.kind) {
|
||||
case 'download-remove':
|
||||
case 'download-stop':
|
||||
onDownloadCancel?.(action.bookId);
|
||||
break;
|
||||
case 'download-dismiss':
|
||||
onDownloadCancel?.(action.bookId);
|
||||
if (action.linkedRequestId) {
|
||||
onRequestDismiss?.(action.linkedRequestId);
|
||||
}
|
||||
break;
|
||||
case 'request-approve':
|
||||
onRequestApprove?.(action.requestId, action.record);
|
||||
break;
|
||||
case 'request-reject':
|
||||
onRequestReject?.(action.requestId);
|
||||
break;
|
||||
case 'request-cancel':
|
||||
onRequestCancel?.(action.requestId);
|
||||
break;
|
||||
case 'request-dismiss':
|
||||
onRequestDismiss?.(action.requestId);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const hasActionHandler = (action: ActivityCardAction): boolean => {
|
||||
switch (action.kind) {
|
||||
case 'download-remove':
|
||||
case 'download-stop':
|
||||
case 'download-dismiss':
|
||||
return Boolean(onDownloadCancel);
|
||||
case 'request-approve':
|
||||
return Boolean(onRequestApprove);
|
||||
case 'request-reject':
|
||||
return Boolean(onRequestReject);
|
||||
case 'request-cancel':
|
||||
return Boolean(onRequestCancel);
|
||||
case 'request-dismiss':
|
||||
return Boolean(onRequestDismiss);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const actions = model.actions.filter(hasActionHandler);
|
||||
|
||||
const titleNode =
|
||||
item.kind === 'download' &&
|
||||
item.visualStatus === 'complete' &&
|
||||
item.downloadPath &&
|
||||
item.downloadBookId ? (
|
||||
<a
|
||||
href={withBasePath(`/api/localdownload?id=${encodeURIComponent(item.downloadBookId)}`)}
|
||||
className="text-sky-600 hover:underline"
|
||||
>
|
||||
{item.title}
|
||||
</a>
|
||||
) : (
|
||||
item.title
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="px-4 py-2 -mx-4 hover-row cursor-default">
|
||||
<div className="flex gap-3 items-start">
|
||||
{/* Artwork */}
|
||||
<div className="w-12 h-[4.5rem] rounded flex-shrink-0 overflow-hidden bg-gray-200 dark:bg-gray-700">
|
||||
{item.preview ? (
|
||||
<img
|
||||
src={item.preview}
|
||||
alt={`${item.title} cover`}
|
||||
className="w-full h-full object-cover object-top"
|
||||
/>
|
||||
) : (
|
||||
<BookFallback />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 py-0.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm truncate leading-tight min-w-0" title={`${item.title} — ${item.author}`}>
|
||||
<span className="font-semibold">{titleNode}</span>
|
||||
{item.author && <span className="opacity-60 text-xs"> — {item.author}</span>}
|
||||
</p>
|
||||
<div className="flex-shrink-0 inline-flex items-center gap-1 -my-1">
|
||||
{actions.map((action) => {
|
||||
const config = actionUiConfig(action);
|
||||
return (
|
||||
<Tooltip
|
||||
key={actionKey(action)}
|
||||
content={config.title}
|
||||
delay={0}
|
||||
position="bottom"
|
||||
>
|
||||
<IconButton
|
||||
title={config.title}
|
||||
className={config.className}
|
||||
onClick={() => runAction(action)}
|
||||
>
|
||||
<ActionIcon icon={config.icon} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs opacity-60 truncate mt-0.5" title={item.metaLine}>
|
||||
{item.metaLine}
|
||||
</p>
|
||||
|
||||
{noteLine && (
|
||||
<p className="text-[11px] opacity-60 italic truncate mt-0.5" title={noteLine}>
|
||||
{noteLine}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-1.5 flex items-center gap-2 min-w-0">
|
||||
{model.badges.map((badge, index) => {
|
||||
const badgeId = `${badge.key}-${index}`;
|
||||
const badgeStyle = STATUS_BADGE_STYLES[badge.visualStatus];
|
||||
const progressConfig = badge.isActiveDownload
|
||||
? getProgressConfig(badge.visualStatus, badge.progress)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={badgeId}
|
||||
content={badgeOverflow[badgeId] ? badge.text : undefined}
|
||||
delay={0}
|
||||
position="bottom"
|
||||
unstyled
|
||||
className={STATUS_TOOLTIP_CLASSES[badge.visualStatus]}
|
||||
>
|
||||
<span
|
||||
ref={(element) => {
|
||||
if (element) {
|
||||
badgeRefs.current[badgeId] = element;
|
||||
} else {
|
||||
delete badgeRefs.current[badgeId];
|
||||
}
|
||||
}}
|
||||
className={`relative px-2 py-0.5 rounded-md text-[11px] font-medium truncate ${badgeStyle.bg} ${badgeStyle.text} ${badge.isActiveDownload ? 'flex-1 min-w-0' : 'inline-block max-w-full'}`}
|
||||
>
|
||||
{progressConfig && badgeStyle.fillColor && (
|
||||
<span
|
||||
className="absolute inset-y-0 left-0 rounded-md overflow-hidden transition-[width] duration-300"
|
||||
style={{ width: `${progressConfig.percent}%` }}
|
||||
>
|
||||
<span
|
||||
className="absolute inset-0 rounded-md"
|
||||
style={{ backgroundColor: badgeStyle.fillColor }}
|
||||
/>
|
||||
<span
|
||||
className="absolute inset-0 rounded-md opacity-30 activity-wave"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.55) 50%, transparent 100%)',
|
||||
backgroundSize: '200% 100%',
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span className="relative">{badge.text}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ActivityVisualStatus } from './activityTypes';
|
||||
import { getProgressConfig, isActiveDownloadStatus } from './activityStyles';
|
||||
|
||||
interface ActivityProgressBarProps {
|
||||
status: ActivityVisualStatus;
|
||||
progress?: number;
|
||||
animated?: boolean;
|
||||
}
|
||||
|
||||
export const ActivityProgressBar = ({
|
||||
status,
|
||||
progress,
|
||||
animated,
|
||||
}: ActivityProgressBarProps) => {
|
||||
if (!isActiveDownloadStatus(status)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = getProgressConfig(status, progress);
|
||||
|
||||
return (
|
||||
<div className="h-1.5 bg-gray-200 dark:bg-gray-700 overflow-hidden relative">
|
||||
<div
|
||||
className={`h-full ${config.color} transition-all duration-300 relative overflow-hidden`}
|
||||
style={{ width: `${Math.min(100, Math.max(0, config.percent))}%` }}
|
||||
>
|
||||
{(animated ?? config.animated) && config.percent < 100 && (
|
||||
<span
|
||||
className="absolute inset-0 opacity-30 activity-wave"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.55) 50%, transparent 100%)',
|
||||
backgroundSize: '200% 100%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,628 @@
|
||||
import { useEffect, useMemo, useRef, useState, type WheelEvent } from 'react';
|
||||
import { RequestRecord, StatusData } from '../../types';
|
||||
import { downloadToActivityItem, DownloadStatusKey } from './activityMappers';
|
||||
import { ActivityItem } from './activityTypes';
|
||||
import { ActivityCard } from './ActivityCard';
|
||||
import { RejectDialog } from './RejectDialog';
|
||||
|
||||
interface ActivitySidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
status: StatusData;
|
||||
isAdmin: boolean;
|
||||
onClearCompleted: () => void;
|
||||
onCancel: (id: string) => void;
|
||||
requestItems: ActivityItem[];
|
||||
pendingRequestCount: number;
|
||||
showRequestsTab: boolean;
|
||||
isRequestsLoading?: boolean;
|
||||
onRequestCancel?: (requestId: number) => Promise<void> | void;
|
||||
onRequestApprove?: (requestId: number, record: RequestRecord) => Promise<void> | void;
|
||||
onRequestReject?: (requestId: number, adminNote?: string) => Promise<void> | void;
|
||||
onRequestDismiss?: (requestId: number) => void;
|
||||
onPinnedOpenChange?: (pinnedOpen: boolean) => void;
|
||||
pinnedTopOffset?: number;
|
||||
}
|
||||
|
||||
export const ACTIVITY_SIDEBAR_PINNED_STORAGE_KEY = 'activity-sidebar-pinned';
|
||||
|
||||
const DOWNLOAD_STATUS_KEYS: DownloadStatusKey[] = [
|
||||
'downloading',
|
||||
'locating',
|
||||
'resolving',
|
||||
'queued',
|
||||
'error',
|
||||
'complete',
|
||||
'cancelled',
|
||||
];
|
||||
|
||||
type ActivityCategoryKey =
|
||||
| 'downloads'
|
||||
| 'pending_requests'
|
||||
| 'fulfilled_requests'
|
||||
| 'other_requests';
|
||||
|
||||
const getCategoryLabel = (
|
||||
key: ActivityCategoryKey,
|
||||
isAdmin: boolean
|
||||
): string => {
|
||||
if (key === 'downloads') {
|
||||
return 'Downloads';
|
||||
}
|
||||
if (key === 'pending_requests') {
|
||||
return 'Pending Requests';
|
||||
}
|
||||
if (key === 'fulfilled_requests') {
|
||||
return isAdmin ? 'Fulfilled Requests' : 'Completed Requests';
|
||||
}
|
||||
return 'Other Requests';
|
||||
};
|
||||
|
||||
const getVisibleCategoryOrder = (
|
||||
tab: 'all' | 'downloads' | 'requests'
|
||||
): ActivityCategoryKey[] => {
|
||||
if (tab === 'downloads') {
|
||||
return ['downloads'];
|
||||
}
|
||||
if (tab === 'requests') {
|
||||
return ['pending_requests', 'fulfilled_requests', 'other_requests'];
|
||||
}
|
||||
return ['downloads', 'pending_requests', 'fulfilled_requests', 'other_requests'];
|
||||
};
|
||||
|
||||
const getActivityCategory = (item: ActivityItem): ActivityCategoryKey => {
|
||||
if (!item.requestId) {
|
||||
return 'downloads';
|
||||
}
|
||||
|
||||
if (item.requestRecord?.status === 'pending' || item.visualStatus === 'pending') {
|
||||
return 'pending_requests';
|
||||
}
|
||||
|
||||
if (item.requestRecord?.status === 'fulfilled' || item.visualStatus === 'fulfilled') {
|
||||
return 'fulfilled_requests';
|
||||
}
|
||||
|
||||
return 'other_requests';
|
||||
};
|
||||
|
||||
const getLinkedDownloadIdFromRequestItem = (item: ActivityItem): string | null => {
|
||||
if (item.kind !== 'request' || item.visualStatus !== 'fulfilled') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const releaseData = item.requestRecord?.release_data;
|
||||
if (!releaseData || typeof releaseData !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceId = (releaseData as Record<string, unknown>).source_id;
|
||||
if (typeof sourceId !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = sourceId.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
};
|
||||
|
||||
const mergeRequestWithDownload = (
|
||||
requestItem: ActivityItem,
|
||||
downloadItem: ActivityItem
|
||||
): ActivityItem => {
|
||||
return {
|
||||
...downloadItem,
|
||||
id: requestItem.id,
|
||||
kind: 'download',
|
||||
title: downloadItem.title || requestItem.title,
|
||||
author: downloadItem.author || requestItem.author,
|
||||
preview: downloadItem.preview || requestItem.preview,
|
||||
metaLine: downloadItem.metaLine,
|
||||
timestamp: Math.max(downloadItem.timestamp, requestItem.timestamp),
|
||||
username: requestItem.username || downloadItem.username,
|
||||
adminNote: requestItem.adminNote,
|
||||
requestId: requestItem.requestId,
|
||||
requestLevel: requestItem.requestLevel,
|
||||
requestNote: requestItem.requestNote,
|
||||
requestRecord: requestItem.requestRecord,
|
||||
};
|
||||
};
|
||||
|
||||
const dedupeById = (items: ActivityItem[]): ActivityItem[] => {
|
||||
const byId = new Map<string, ActivityItem>();
|
||||
items.forEach((item) => {
|
||||
const current = byId.get(item.id);
|
||||
if (!current || item.timestamp >= current.timestamp) {
|
||||
byId.set(item.id, item);
|
||||
}
|
||||
});
|
||||
return Array.from(byId.values());
|
||||
};
|
||||
|
||||
const parsePinned = (value: string | null): boolean => {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
return value === '1' || value.toLowerCase() === 'true';
|
||||
};
|
||||
|
||||
const getInitialPinnedPreference = (): boolean => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return parsePinned(window.localStorage.getItem(ACTIVITY_SIDEBAR_PINNED_STORAGE_KEY));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const getInitialDesktopState = (): boolean => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
return window.matchMedia('(min-width: 1024px)').matches;
|
||||
};
|
||||
|
||||
export const ActivitySidebar = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
status,
|
||||
isAdmin,
|
||||
onClearCompleted,
|
||||
onCancel,
|
||||
requestItems,
|
||||
pendingRequestCount,
|
||||
showRequestsTab,
|
||||
isRequestsLoading = false,
|
||||
onRequestCancel,
|
||||
onRequestApprove,
|
||||
onRequestReject,
|
||||
onRequestDismiss,
|
||||
onPinnedOpenChange,
|
||||
pinnedTopOffset = 0,
|
||||
}: ActivitySidebarProps) => {
|
||||
const [isPinned, setIsPinned] = useState<boolean>(() => getInitialPinnedPreference());
|
||||
const [isDesktop, setIsDesktop] = useState<boolean>(() => getInitialDesktopState());
|
||||
const [activeTab, setActiveTab] = useState<'all' | 'downloads' | 'requests'>('all');
|
||||
const [rejectingRequest, setRejectingRequest] = useState<{ requestId: number; bookTitle: string } | null>(null);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
||||
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(min-width: 1024px)');
|
||||
|
||||
const handleMediaChange = (event: MediaQueryListEvent) => {
|
||||
setIsDesktop(event.matches);
|
||||
};
|
||||
|
||||
setIsDesktop(mediaQuery.matches);
|
||||
mediaQuery.addEventListener('change', handleMediaChange);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handleMediaChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showRequestsTab && activeTab === 'requests') {
|
||||
setActiveTab('all');
|
||||
}
|
||||
}, [showRequestsTab, activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'downloads') {
|
||||
setRejectingRequest(null);
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
const isPinnedOpen = isOpen && isDesktop && isPinned;
|
||||
|
||||
useEffect(() => {
|
||||
onPinnedOpenChange?.(isPinnedOpen);
|
||||
}, [isPinnedOpen, onPinnedOpenChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || isPinnedOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', onEscape);
|
||||
return () => document.removeEventListener('keydown', onEscape);
|
||||
}, [isOpen, isPinnedOpen, onClose]);
|
||||
|
||||
const downloadItems = useMemo(() => {
|
||||
const items: ActivityItem[] = [];
|
||||
|
||||
DOWNLOAD_STATUS_KEYS.forEach((statusKey) => {
|
||||
const bucket = status[statusKey];
|
||||
if (!bucket) {
|
||||
return;
|
||||
}
|
||||
Object.values(bucket).forEach((book) => {
|
||||
items.push(downloadToActivityItem(book, statusKey));
|
||||
});
|
||||
});
|
||||
|
||||
return items.sort((left, right) => right.timestamp - left.timestamp);
|
||||
}, [status]);
|
||||
|
||||
const { mergedRequestItems, mergedDownloadItems } = useMemo(() => {
|
||||
const downloadsById = new Map<string, ActivityItem>();
|
||||
downloadItems.forEach((item) => {
|
||||
if (item.downloadBookId) {
|
||||
downloadsById.set(item.downloadBookId, item);
|
||||
}
|
||||
});
|
||||
|
||||
const mergedByDownloadId = new Map<string, ActivityItem>();
|
||||
const nextRequestItems = requestItems.map((requestItem) => {
|
||||
const linkedDownloadId = getLinkedDownloadIdFromRequestItem(requestItem);
|
||||
if (!linkedDownloadId) {
|
||||
return requestItem;
|
||||
}
|
||||
|
||||
const matchedDownload = downloadsById.get(linkedDownloadId);
|
||||
if (!matchedDownload) {
|
||||
return requestItem;
|
||||
}
|
||||
|
||||
const merged = mergeRequestWithDownload(requestItem, matchedDownload);
|
||||
if (!mergedByDownloadId.has(linkedDownloadId)) {
|
||||
mergedByDownloadId.set(linkedDownloadId, merged);
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
|
||||
const nextDownloadItems = downloadItems.map((downloadItem) => {
|
||||
const downloadId = downloadItem.downloadBookId;
|
||||
if (!downloadId) {
|
||||
return downloadItem;
|
||||
}
|
||||
return mergedByDownloadId.get(downloadId) || downloadItem;
|
||||
});
|
||||
|
||||
return {
|
||||
mergedRequestItems: nextRequestItems,
|
||||
mergedDownloadItems: nextDownloadItems,
|
||||
};
|
||||
}, [downloadItems, requestItems]);
|
||||
|
||||
const hasTerminalDownloadItems = useMemo(
|
||||
() =>
|
||||
mergedDownloadItems.some(
|
||||
(item) =>
|
||||
item.visualStatus === 'complete' || item.visualStatus === 'error' || item.visualStatus === 'cancelled'
|
||||
),
|
||||
[mergedDownloadItems]
|
||||
);
|
||||
|
||||
const allItems = useMemo(() => {
|
||||
const combined = dedupeById([...mergedDownloadItems, ...mergedRequestItems]);
|
||||
return combined.sort((a, b) => b.timestamp - a.timestamp);
|
||||
}, [mergedDownloadItems, mergedRequestItems]);
|
||||
|
||||
const visibleItems = activeTab === 'all'
|
||||
? allItems
|
||||
: activeTab === 'requests'
|
||||
? mergedRequestItems
|
||||
: mergedDownloadItems;
|
||||
|
||||
const visibleCategoryOrder = useMemo(
|
||||
() => getVisibleCategoryOrder(activeTab),
|
||||
[activeTab]
|
||||
);
|
||||
|
||||
const groupedVisibleItems = useMemo(() => {
|
||||
const grouped = new Map<ActivityCategoryKey, ActivityItem[]>();
|
||||
visibleCategoryOrder.forEach((key) => grouped.set(key, []));
|
||||
|
||||
visibleItems.forEach((item) => {
|
||||
const category = activeTab === 'downloads' ? 'downloads' : getActivityCategory(item);
|
||||
if (!grouped.has(category)) {
|
||||
grouped.set(category, []);
|
||||
}
|
||||
grouped.get(category)!.push(item);
|
||||
});
|
||||
|
||||
return visibleCategoryOrder
|
||||
.map((key) => ({
|
||||
key,
|
||||
label: getCategoryLabel(key, isAdmin),
|
||||
items: (grouped.get(key) || []).sort((left, right) => right.timestamp - left.timestamp),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0);
|
||||
}, [activeTab, isAdmin, visibleItems, visibleCategoryOrder]);
|
||||
|
||||
const handleTogglePinned = () => {
|
||||
const next = !isPinned;
|
||||
setIsPinned(next);
|
||||
try {
|
||||
window.localStorage.setItem(ACTIVITY_SIDEBAR_PINNED_STORAGE_KEY, next ? '1' : '0');
|
||||
} catch {
|
||||
// Ignore storage failures
|
||||
}
|
||||
};
|
||||
|
||||
// Tab indicator (sliding underline, same pattern as ReleaseModal)
|
||||
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
const [tabIndicatorStyle, setTabIndicatorStyle] = useState({ left: 0, width: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const activeButton = tabRefs.current[activeTab];
|
||||
if (activeButton) {
|
||||
const containerRect = activeButton.parentElement?.getBoundingClientRect();
|
||||
const buttonRect = activeButton.getBoundingClientRect();
|
||||
if (containerRect) {
|
||||
setTabIndicatorStyle({
|
||||
left: buttonRect.left - containerRect.left,
|
||||
width: buttonRect.width,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [activeTab, showRequestsTab]);
|
||||
|
||||
const panel = (
|
||||
<>
|
||||
<div
|
||||
className={`px-4 pt-4 ${showRequestsTab ? 'pb-0' : 'pb-4 border-b'}`}
|
||||
style={{
|
||||
borderColor: 'var(--border-muted)',
|
||||
paddingTop: 'calc(1rem + env(safe-area-inset-top))',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="text-lg font-semibold">Activity</h2>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTogglePinned}
|
||||
className="hidden lg:inline-flex h-9 w-9 items-center justify-center rounded-full hover-action transition-colors"
|
||||
title={isPinned ? 'Unpin activity sidebar' : 'Pin activity sidebar'}
|
||||
aria-label={isPinned ? 'Unpin activity sidebar' : 'Pin activity sidebar'}
|
||||
>
|
||||
{isPinned ? (
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M15.804 2.276a.75.75 0 0 0-.336.195l-2 2a.75.75 0 0 0 0 1.062l.47.469-3.572 3.571c-.83-.534-1.773-.808-2.709-.691-1.183.148-2.32.72-3.187 1.587a.75.75 0 0 0 0 1.063L7.938 15l-5.467 5.467a.75.75 0 0 0 0 1.062.75.75 0 0 0 1.062 0L9 16.062l3.468 3.468a.75.75 0 0 0 1.062 0c.868-.868 1.44-2.004 1.588-3.187.117-.935-.158-1.879-.692-2.708L18 10.063l.469.469a.75.75 0 0 0 1.062 0l2-2a.75.75 0 0 0 0-1.062l-5-4.999a.75.75 0 0 0-.726-.195z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m9 15-6 6M15 6l-1-1 2-2 5 5-2 2-1-1-4.5 4.5c1.5 1.5 1 4-.5 5.5l-8-8c1.5-1.5 4-2 5.5-.5z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="h-9 w-9 inline-flex items-center justify-center rounded-full hover-action transition-colors"
|
||||
aria-label="Close activity sidebar"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showRequestsTab && (
|
||||
<div className="mt-2 border-b border-[var(--border-muted)] -mx-4 px-4">
|
||||
<div className="relative flex gap-1">
|
||||
{/* Sliding indicator */}
|
||||
<div
|
||||
className="absolute bottom-0 h-0.5 bg-sky-500 transition-all duration-300 ease-out"
|
||||
style={{
|
||||
left: tabIndicatorStyle.left,
|
||||
width: tabIndicatorStyle.width,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
ref={(el) => { tabRefs.current.all = el; }}
|
||||
onClick={() => setActiveTab('all')}
|
||||
className={`px-4 py-2.5 text-sm font-medium border-b-2 border-transparent transition-colors whitespace-nowrap ${
|
||||
activeTab === 'all'
|
||||
? 'text-sky-600 dark:text-sky-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||
}`}
|
||||
aria-current={activeTab === 'all' ? 'page' : undefined}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
ref={(el) => { tabRefs.current.downloads = el; }}
|
||||
onClick={() => setActiveTab('downloads')}
|
||||
className={`px-4 py-2.5 text-sm font-medium border-b-2 border-transparent transition-colors whitespace-nowrap ${
|
||||
activeTab === 'downloads'
|
||||
? 'text-sky-600 dark:text-sky-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||
}`}
|
||||
aria-current={activeTab === 'downloads' ? 'page' : undefined}
|
||||
>
|
||||
Downloads
|
||||
{mergedDownloadItems.length > 0 && (
|
||||
<span className="ml-1.5 text-[11px] h-[18px] min-w-[18px] px-1 rounded-full bg-sky-500/15 text-sky-700 dark:text-sky-300 inline-flex items-center justify-center leading-none">
|
||||
{mergedDownloadItems.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
ref={(el) => { tabRefs.current.requests = el; }}
|
||||
onClick={() => setActiveTab('requests')}
|
||||
className={`px-4 py-2.5 text-sm font-medium border-b-2 border-transparent transition-colors whitespace-nowrap ${
|
||||
activeTab === 'requests'
|
||||
? 'text-sky-600 dark:text-sky-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||
}`}
|
||||
aria-current={activeTab === 'requests' ? 'page' : undefined}
|
||||
>
|
||||
Requests
|
||||
{pendingRequestCount > 0 && (
|
||||
<span className="ml-1.5 text-[11px] h-[18px] min-w-[18px] px-1 rounded-full bg-amber-500/15 text-amber-700 dark:text-amber-300 inline-flex items-center justify-center leading-none">
|
||||
{pendingRequestCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollViewportRef}
|
||||
className="flex-1 overflow-y-auto overscroll-y-contain p-4"
|
||||
style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}
|
||||
>
|
||||
{visibleItems.length === 0 ? (
|
||||
<p className="text-center text-sm opacity-70 mt-8">
|
||||
{activeTab === 'requests'
|
||||
? isRequestsLoading ? 'Loading requests...' : 'No requests'
|
||||
: activeTab === 'downloads'
|
||||
? 'No downloads'
|
||||
: 'No activity'}
|
||||
</p>
|
||||
) : (
|
||||
groupedVisibleItems.map((group) => (
|
||||
<section key={group.key} className="mb-4 last:mb-0">
|
||||
{activeTab !== 'downloads' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsedGroups((prev) => ({ ...prev, [group.key]: !prev[group.key] }))}
|
||||
className="mb-2 w-full flex items-center justify-between text-[11px] uppercase tracking-wide opacity-70 hover:opacity-100 transition-opacity cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform ${collapsedGroups[group.key] ? '-rotate-90' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
<span>{group.label}</span>
|
||||
</div>
|
||||
<span className="rounded-full h-[18px] min-w-[18px] px-1 bg-gray-500/10 dark:bg-gray-400/10 inline-flex items-center justify-center leading-none">{group.items.length}</span>
|
||||
</button>
|
||||
)}
|
||||
{!collapsedGroups[group.key] && (
|
||||
<div className="divide-y divide-[color-mix(in_srgb,var(--border-muted)_60%,transparent)]">
|
||||
{group.items.map((item) => {
|
||||
const showRequestActions = activeTab === 'requests' || activeTab === 'all';
|
||||
const shouldShowRejectDialog =
|
||||
showRequestActions &&
|
||||
rejectingRequest !== null &&
|
||||
item.requestId === rejectingRequest.requestId;
|
||||
|
||||
return (
|
||||
<div key={item.id}>
|
||||
<ActivityCard
|
||||
item={item}
|
||||
isAdmin={isAdmin}
|
||||
onDownloadCancel={onCancel}
|
||||
onRequestCancel={onRequestCancel}
|
||||
onRequestApprove={onRequestApprove}
|
||||
onRequestDismiss={onRequestDismiss}
|
||||
onRequestReject={
|
||||
showRequestActions && onRequestReject
|
||||
? (requestId) => {
|
||||
const title = item.title || 'Untitled request';
|
||||
setRejectingRequest({ requestId, bookTitle: title });
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{shouldShowRejectDialog && onRequestReject && (
|
||||
<RejectDialog
|
||||
requestId={rejectingRequest.requestId}
|
||||
bookTitle={rejectingRequest.bookTitle}
|
||||
onConfirm={onRequestReject}
|
||||
onCancel={() => setRejectingRequest(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(activeTab === 'downloads' || activeTab === 'all') && hasTerminalDownloadItems && (
|
||||
<div
|
||||
className="p-3 border-t flex items-center justify-center"
|
||||
style={{
|
||||
borderColor: 'var(--border-muted)',
|
||||
paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom))',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearCompleted}
|
||||
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
Clear Completed
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (isPinnedOpen) {
|
||||
const handlePinnedWheel = (event: WheelEvent<HTMLElement>) => {
|
||||
const viewport = scrollViewportRef.current;
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
// Keep wheel/trackpad scrolling contained to the pinned activity panel.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
viewport.scrollTop += event.deltaY;
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="hidden lg:flex fixed right-0 w-96 flex-col bg-[var(--bg-soft)] z-30 rounded-2xl shadow-lg overflow-hidden"
|
||||
style={{
|
||||
top: `calc(${pinnedTopOffset}px + 0.75rem)`,
|
||||
height: `calc(100dvh - ${pinnedTopOffset}px - 1.5rem)`,
|
||||
right: '0.75rem',
|
||||
}}
|
||||
onWheel={handlePinnedWheel}
|
||||
aria-hidden={!isOpen}
|
||||
>
|
||||
{panel}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`fixed inset-0 bg-black/50 z-[45] transition-opacity duration-300 ${
|
||||
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<aside
|
||||
className={`fixed top-0 right-0 h-full w-full sm:w-96 z-50 flex flex-col shadow-2xl transition-transform duration-300 ${
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
style={{ background: 'var(--bg)' }}
|
||||
aria-hidden={!isOpen}
|
||||
>
|
||||
{panel}
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface RejectDialogProps {
|
||||
requestId: number;
|
||||
bookTitle: string;
|
||||
onConfirm: (requestId: number, adminNote?: string) => Promise<void> | void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const MAX_ADMIN_NOTE_LENGTH = 1000;
|
||||
|
||||
export const RejectDialog = ({
|
||||
requestId,
|
||||
bookTitle,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: RejectDialogProps) => {
|
||||
const [adminNote, setAdminNote] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !isSubmitting) {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', onEscape);
|
||||
return () => document.removeEventListener('keydown', onEscape);
|
||||
}, [isSubmitting, onCancel]);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const trimmed = adminNote.trim();
|
||||
await onConfirm(requestId, trimmed || undefined);
|
||||
onCancel();
|
||||
} catch {
|
||||
// Parent handler surfaces the error state/toast.
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--border-muted)] bg-[var(--bg)] p-3 mt-2 space-y-2">
|
||||
<p className="text-xs font-medium">
|
||||
Reject request for <span className="opacity-80">{bookTitle}</span>
|
||||
</p>
|
||||
<textarea
|
||||
value={adminNote}
|
||||
onChange={(event) => setAdminNote(event.target.value.slice(0, MAX_ADMIN_NOTE_LENGTH))}
|
||||
rows={3}
|
||||
maxLength={MAX_ADMIN_NOTE_LENGTH}
|
||||
placeholder="Optional note shown to the user"
|
||||
className="w-full px-2.5 py-2 rounded-md border border-[var(--border-muted)] bg-[var(--bg-soft)] text-xs resize-y min-h-[72px] focus:outline-none focus:ring-2 focus:ring-red-500/30 focus:border-red-500"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] opacity-60">{adminNote.length}/{MAX_ADMIN_NOTE_LENGTH}</span>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
className="px-2.5 py-1.5 rounded-md text-xs border border-[var(--border-muted)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
disabled={isSubmitting}
|
||||
className="px-2.5 py-1.5 rounded-md text-xs font-medium text-white bg-red-600 hover:bg-red-700 transition-colors disabled:opacity-60"
|
||||
>
|
||||
{isSubmitting ? 'Rejecting...' : 'Reject'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,201 @@
|
||||
import { RequestRecord } from '../../types';
|
||||
import { isActiveDownloadStatus } from './activityStyles.js';
|
||||
import { ActivityItem, ActivityVisualStatus } from './activityTypes';
|
||||
|
||||
export type ActivityCardAction =
|
||||
| {
|
||||
kind: 'download-remove' | 'download-stop' | 'download-dismiss';
|
||||
bookId: string;
|
||||
linkedRequestId?: number;
|
||||
}
|
||||
| {
|
||||
kind: 'request-approve';
|
||||
requestId: number;
|
||||
record: RequestRecord;
|
||||
}
|
||||
| {
|
||||
kind: 'request-reject' | 'request-cancel' | 'request-dismiss';
|
||||
requestId: number;
|
||||
};
|
||||
|
||||
export interface ActivityCardBadge {
|
||||
key: 'download' | 'request' | 'status';
|
||||
text: string;
|
||||
visualStatus: ActivityVisualStatus;
|
||||
isActiveDownload: boolean;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
export interface ActivityCardModel {
|
||||
badges: ActivityCardBadge[];
|
||||
noteLine?: string;
|
||||
actions: ActivityCardAction[];
|
||||
}
|
||||
|
||||
const formatDownloadProgress = (progress: number, sizeRaw?: string): string => {
|
||||
if (sizeRaw) {
|
||||
const sizeValue = parseFloat(sizeRaw.replace(/[^\d.]/g, ''));
|
||||
const sizeUnit = sizeRaw.replace(/[\d.\s]/g, '');
|
||||
if (sizeValue > 0) {
|
||||
const downloaded = (progress / 100) * sizeValue;
|
||||
return `${downloaded.toFixed(1)}${sizeUnit} / ${sizeRaw}`;
|
||||
}
|
||||
}
|
||||
return `Downloading ${Math.round(progress)}%`;
|
||||
};
|
||||
|
||||
const toRequestVisualStatus = (status: RequestRecord['status']): ActivityVisualStatus => {
|
||||
if (status === 'pending') return 'pending';
|
||||
if (status === 'fulfilled') return 'fulfilled';
|
||||
if (status === 'rejected') return 'rejected';
|
||||
return 'cancelled';
|
||||
};
|
||||
|
||||
const getPendingRequestText = (item: ActivityItem, isAdmin: boolean): string => {
|
||||
if (!isAdmin) {
|
||||
return 'Pending';
|
||||
}
|
||||
const username = item.username?.trim() || item.requestRecord?.username?.trim();
|
||||
return username ? `Requested by ${username}` : 'Requested';
|
||||
};
|
||||
|
||||
const getRequestBadge = (item: ActivityItem, isAdmin: boolean): ActivityCardBadge => {
|
||||
const requestVisualStatus = item.requestRecord
|
||||
? toRequestVisualStatus(item.requestRecord.status)
|
||||
: item.visualStatus;
|
||||
const hasInFlightLinkedDownload = (
|
||||
item.kind === 'download' &&
|
||||
requestVisualStatus === 'fulfilled' &&
|
||||
isActiveDownloadStatus(item.visualStatus)
|
||||
);
|
||||
const visualStatus = hasInFlightLinkedDownload ? 'resolving' : requestVisualStatus;
|
||||
|
||||
let text = item.statusLabel;
|
||||
if (hasInFlightLinkedDownload) {
|
||||
text = isAdmin ? 'Request approved' : 'Approved';
|
||||
} else if (requestVisualStatus === 'pending') {
|
||||
text = getPendingRequestText(item, isAdmin);
|
||||
} else if (requestVisualStatus === 'fulfilled') {
|
||||
text = isAdmin ? 'Request fulfilled' : 'Approved';
|
||||
} else if (requestVisualStatus === 'rejected') {
|
||||
text = 'Rejected';
|
||||
} else if (requestVisualStatus === 'cancelled') {
|
||||
text = 'Cancelled';
|
||||
}
|
||||
|
||||
return {
|
||||
key: 'request',
|
||||
text,
|
||||
visualStatus,
|
||||
isActiveDownload: false,
|
||||
};
|
||||
};
|
||||
|
||||
const getDownloadBadge = (item: ActivityItem): ActivityCardBadge => {
|
||||
let text = item.statusLabel;
|
||||
if (item.statusDetail) {
|
||||
text = item.statusDetail;
|
||||
} else if (item.visualStatus === 'downloading' && typeof item.progress === 'number') {
|
||||
text = formatDownloadProgress(item.progress, item.sizeRaw);
|
||||
}
|
||||
|
||||
return {
|
||||
key: 'download',
|
||||
text,
|
||||
visualStatus: item.visualStatus,
|
||||
isActiveDownload: isActiveDownloadStatus(item.visualStatus),
|
||||
progress: item.progress,
|
||||
};
|
||||
};
|
||||
|
||||
const buildBadges = (item: ActivityItem, isAdmin: boolean): ActivityCardBadge[] => {
|
||||
if (item.kind === 'download' && item.requestId && item.requestRecord) {
|
||||
return [getRequestBadge(item, isAdmin), getDownloadBadge(item)];
|
||||
}
|
||||
|
||||
if (item.kind === 'request') {
|
||||
return [getRequestBadge(item, isAdmin)];
|
||||
}
|
||||
|
||||
return [getDownloadBadge(item)];
|
||||
};
|
||||
|
||||
const buildRequestNoteLine = (item: ActivityItem): string | undefined => {
|
||||
const requestStatus = item.requestRecord?.status;
|
||||
if (item.requestNote && (requestStatus === 'pending' || item.visualStatus === 'pending')) {
|
||||
return `"${item.requestNote}"`;
|
||||
}
|
||||
if (
|
||||
item.adminNote &&
|
||||
(
|
||||
requestStatus === 'rejected' ||
|
||||
requestStatus === 'fulfilled' ||
|
||||
item.visualStatus === 'rejected' ||
|
||||
item.visualStatus === 'fulfilled'
|
||||
)
|
||||
) {
|
||||
return `"${item.adminNote}"`;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const buildActions = (item: ActivityItem, isAdmin: boolean): ActivityCardAction[] => {
|
||||
if (item.kind === 'download' && item.downloadBookId) {
|
||||
if (item.visualStatus === 'queued') {
|
||||
return [{ kind: 'download-remove', bookId: item.downloadBookId }];
|
||||
}
|
||||
if (
|
||||
item.visualStatus === 'resolving' ||
|
||||
item.visualStatus === 'locating' ||
|
||||
item.visualStatus === 'downloading'
|
||||
) {
|
||||
return [{ kind: 'download-stop', bookId: item.downloadBookId }];
|
||||
}
|
||||
return [
|
||||
{
|
||||
kind: 'download-dismiss',
|
||||
bookId: item.downloadBookId,
|
||||
linkedRequestId: item.requestId,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (item.kind === 'request' && item.requestId) {
|
||||
if (item.visualStatus === 'pending') {
|
||||
if (isAdmin) {
|
||||
const actions: ActivityCardAction[] = [];
|
||||
if (item.requestRecord) {
|
||||
actions.push({
|
||||
kind: 'request-approve',
|
||||
requestId: item.requestId,
|
||||
record: item.requestRecord,
|
||||
});
|
||||
}
|
||||
actions.push({ kind: 'request-reject', requestId: item.requestId });
|
||||
return actions;
|
||||
}
|
||||
return [{ kind: 'request-cancel', requestId: item.requestId }];
|
||||
}
|
||||
|
||||
if (
|
||||
item.visualStatus === 'fulfilled' ||
|
||||
item.visualStatus === 'rejected' ||
|
||||
item.visualStatus === 'cancelled'
|
||||
) {
|
||||
return [{ kind: 'request-dismiss', requestId: item.requestId }];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
export const buildActivityCardModel = (
|
||||
item: ActivityItem,
|
||||
isAdmin: boolean
|
||||
): ActivityCardModel => {
|
||||
return {
|
||||
badges: buildBadges(item, isAdmin),
|
||||
noteLine: buildRequestNoteLine(item),
|
||||
actions: buildActions(item, isAdmin),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Book, RequestRecord, StatusData } from '../../types';
|
||||
import { ActivityItem, ActivityVisualStatus } from './activityTypes';
|
||||
import { STATUS_LABELS, isActiveDownloadStatus } from './activityStyles.js';
|
||||
|
||||
export type DownloadStatusKey = Extract<
|
||||
keyof StatusData,
|
||||
'queued' | 'resolving' | 'locating' | 'downloading' | 'complete' | 'error' | 'cancelled'
|
||||
>;
|
||||
|
||||
const toText = (value: unknown, fallback: string): string => {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const toOptionalText = (value: unknown): string | undefined => {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const toNumber = (value: unknown, fallback = 0): number => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const toSourceLabel = (value: unknown): string | undefined => {
|
||||
const text = toOptionalText(value);
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
return text
|
||||
.split('_')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
const joinMetaParts = (parts: Array<string | undefined>): string => {
|
||||
return parts.filter((part): part is string => Boolean(part && part.trim())).join(' · ');
|
||||
};
|
||||
|
||||
const statusKeyToVisualStatus = (statusKey: DownloadStatusKey): ActivityVisualStatus => {
|
||||
if (statusKey === 'queued') return 'queued';
|
||||
if (statusKey === 'resolving') return 'resolving';
|
||||
if (statusKey === 'locating') return 'locating';
|
||||
if (statusKey === 'downloading') return 'downloading';
|
||||
if (statusKey === 'complete') return 'complete';
|
||||
if (statusKey === 'error') return 'error';
|
||||
return 'cancelled';
|
||||
};
|
||||
|
||||
const getDownloadProgress = (status: ActivityVisualStatus, bookProgress: unknown): number | undefined => {
|
||||
if (status === 'queued') return 5;
|
||||
if (status === 'resolving') return 15;
|
||||
if (status === 'locating') return 90;
|
||||
if (status === 'downloading') {
|
||||
const progress = typeof bookProgress === 'number' ? Math.max(0, Math.min(100, bookProgress)) : 0;
|
||||
return Math.max(0, Math.min(100, 20 + progress * 0.8));
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const downloadToActivityItem = (book: Book, statusKey: DownloadStatusKey): ActivityItem => {
|
||||
const visualStatus = statusKeyToVisualStatus(statusKey);
|
||||
const metaLine = joinMetaParts([
|
||||
toOptionalText(book.format)?.toUpperCase(),
|
||||
toOptionalText(book.size),
|
||||
toOptionalText(book.source_display_name) || toSourceLabel(book.source),
|
||||
toOptionalText(book.username),
|
||||
]);
|
||||
const progress = getDownloadProgress(visualStatus, book.progress);
|
||||
const statusDetail = toOptionalText(book.status_message);
|
||||
|
||||
return {
|
||||
id: book.id,
|
||||
kind: 'download',
|
||||
visualStatus,
|
||||
title: toText(book.title, 'Unknown title'),
|
||||
author: toText(book.author, 'Unknown author'),
|
||||
preview: toOptionalText(book.preview),
|
||||
metaLine,
|
||||
statusLabel: STATUS_LABELS[visualStatus],
|
||||
statusDetail,
|
||||
progress,
|
||||
progressAnimated: isActiveDownloadStatus(visualStatus),
|
||||
timestamp: toNumber(book.added_time, 0),
|
||||
username: toOptionalText(book.username),
|
||||
downloadBookId: book.id,
|
||||
downloadPath: toOptionalText(book.download_path),
|
||||
sizeRaw: toOptionalText(book.size),
|
||||
};
|
||||
};
|
||||
|
||||
const parseRecordData = (value: unknown): Record<string, unknown> => {
|
||||
if (value && typeof value === 'object') {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
const requestStatusToVisualStatus = (status: RequestRecord['status']): ActivityVisualStatus => {
|
||||
if (status === 'pending') return 'pending';
|
||||
if (status === 'fulfilled') return 'fulfilled';
|
||||
if (status === 'rejected') return 'rejected';
|
||||
return 'cancelled';
|
||||
};
|
||||
|
||||
const buildRequestMetaLine = (
|
||||
record: RequestRecord,
|
||||
releaseData: Record<string, unknown>,
|
||||
viewerRole: 'user' | 'admin'
|
||||
): string => {
|
||||
if (record.request_level === 'book') {
|
||||
const username = viewerRole === 'admin' ? toOptionalText(record.username) : undefined;
|
||||
return joinMetaParts(['Book request', username]);
|
||||
}
|
||||
|
||||
const format = toOptionalText(releaseData.format)?.toUpperCase();
|
||||
const size = toOptionalText(releaseData.size);
|
||||
const source = toSourceLabel(releaseData.source || record.source_hint);
|
||||
const username = viewerRole === 'admin' ? toOptionalText(record.username) : undefined;
|
||||
|
||||
const line = joinMetaParts([format, size, source, username]);
|
||||
return line || joinMetaParts(['Release request', username]);
|
||||
};
|
||||
|
||||
export const requestToActivityItem = (
|
||||
record: RequestRecord,
|
||||
viewerRole: 'user' | 'admin'
|
||||
): ActivityItem => {
|
||||
const visualStatus = requestStatusToVisualStatus(record.status);
|
||||
const bookData = parseRecordData(record.book_data);
|
||||
const releaseData = parseRecordData(record.release_data);
|
||||
|
||||
const timestamp = Number.isFinite(Date.parse(record.created_at))
|
||||
? Date.parse(record.created_at)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
id: `request-${record.id}`,
|
||||
kind: 'request',
|
||||
visualStatus,
|
||||
title: toText(bookData.title ?? releaseData.title, 'Unknown title'),
|
||||
author: toText(bookData.author ?? releaseData.author, 'Unknown author'),
|
||||
preview: toOptionalText(bookData.preview) || toOptionalText(releaseData.preview),
|
||||
metaLine: buildRequestMetaLine(record, releaseData, viewerRole),
|
||||
statusLabel: STATUS_LABELS[visualStatus],
|
||||
adminNote: toOptionalText(record.admin_note),
|
||||
timestamp,
|
||||
username: toOptionalText(record.username),
|
||||
requestId: record.id,
|
||||
requestLevel: record.request_level,
|
||||
requestNote: toOptionalText(record.note),
|
||||
requestRecord: record,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
import { ActivityVisualStatus } from './activityTypes';
|
||||
|
||||
export const STATUS_ACCENT_CLASSES: Record<ActivityVisualStatus, string> = {
|
||||
queued: 'border-l-amber-500',
|
||||
pending: 'border-l-amber-500',
|
||||
resolving: 'border-l-indigo-500',
|
||||
locating: 'border-l-teal-500',
|
||||
downloading: 'border-l-sky-500',
|
||||
complete: 'border-l-green-500',
|
||||
fulfilled: 'border-l-green-500',
|
||||
error: 'border-l-red-500',
|
||||
rejected: 'border-l-red-500',
|
||||
cancelled: 'border-l-gray-400',
|
||||
};
|
||||
|
||||
export const STATUS_LABELS: Record<ActivityVisualStatus, string> = {
|
||||
queued: 'Queued',
|
||||
pending: 'Pending',
|
||||
resolving: 'Resolving',
|
||||
locating: 'Locating files',
|
||||
downloading: 'Downloading',
|
||||
complete: 'Complete',
|
||||
fulfilled: 'Fulfilled',
|
||||
error: 'Error',
|
||||
rejected: 'Rejected',
|
||||
cancelled: 'Cancelled',
|
||||
};
|
||||
|
||||
export interface StatusBadgeStyle {
|
||||
bg: string;
|
||||
text: string;
|
||||
waveColor?: string;
|
||||
fillColor?: string;
|
||||
}
|
||||
|
||||
export const STATUS_BADGE_STYLES: Record<ActivityVisualStatus, StatusBadgeStyle> = {
|
||||
queued: {
|
||||
bg: 'bg-amber-500/15',
|
||||
text: 'text-amber-700 dark:text-amber-300',
|
||||
waveColor: 'rgba(217, 119, 6, 0.3)',
|
||||
fillColor: 'rgba(217, 119, 6, 0.4)',
|
||||
},
|
||||
pending: {
|
||||
bg: 'bg-amber-500/15',
|
||||
text: 'text-amber-700 dark:text-amber-300',
|
||||
},
|
||||
resolving: {
|
||||
bg: 'bg-indigo-500/15',
|
||||
text: 'text-indigo-700 dark:text-indigo-300',
|
||||
waveColor: 'rgba(79, 70, 229, 0.3)',
|
||||
fillColor: 'rgba(79, 70, 229, 0.4)',
|
||||
},
|
||||
locating: {
|
||||
bg: 'bg-teal-500/15',
|
||||
text: 'text-teal-700 dark:text-teal-300',
|
||||
waveColor: 'rgba(13, 148, 136, 0.3)',
|
||||
fillColor: 'rgba(13, 148, 136, 0.4)',
|
||||
},
|
||||
downloading: {
|
||||
bg: 'bg-sky-500/15',
|
||||
text: 'text-sky-700 dark:text-sky-300',
|
||||
waveColor: 'rgba(2, 132, 199, 0.3)',
|
||||
fillColor: 'rgba(2, 132, 199, 0.4)',
|
||||
},
|
||||
complete: {
|
||||
bg: 'bg-green-500/15',
|
||||
text: 'text-green-700 dark:text-green-300',
|
||||
},
|
||||
fulfilled: {
|
||||
bg: 'bg-green-500/15',
|
||||
text: 'text-green-700 dark:text-green-300',
|
||||
},
|
||||
error: {
|
||||
bg: 'bg-red-500/15',
|
||||
text: 'text-red-700 dark:text-red-300',
|
||||
},
|
||||
rejected: {
|
||||
bg: 'bg-red-500/15',
|
||||
text: 'text-red-700 dark:text-red-300',
|
||||
},
|
||||
cancelled: {
|
||||
bg: 'bg-gray-500/15',
|
||||
text: 'text-gray-600 dark:text-gray-400',
|
||||
},
|
||||
};
|
||||
|
||||
export const STATUS_TOOLTIP_CLASSES: Record<ActivityVisualStatus, string> = {
|
||||
queued: 'bg-amber-50 text-amber-800 border border-amber-300/50 dark:bg-amber-950 dark:text-amber-200 dark:border-amber-700/50',
|
||||
pending: 'bg-amber-50 text-amber-800 border border-amber-300/50 dark:bg-amber-950 dark:text-amber-200 dark:border-amber-700/50',
|
||||
resolving: 'bg-indigo-50 text-indigo-800 border border-indigo-300/50 dark:bg-indigo-950 dark:text-indigo-200 dark:border-indigo-700/50',
|
||||
locating: 'bg-teal-50 text-teal-800 border border-teal-300/50 dark:bg-teal-950 dark:text-teal-200 dark:border-teal-700/50',
|
||||
downloading: 'bg-sky-50 text-sky-800 border border-sky-300/50 dark:bg-sky-950 dark:text-sky-200 dark:border-sky-700/50',
|
||||
complete: 'bg-green-50 text-green-800 border border-green-300/50 dark:bg-green-950 dark:text-green-200 dark:border-green-700/50',
|
||||
fulfilled: 'bg-green-50 text-green-800 border border-green-300/50 dark:bg-green-950 dark:text-green-200 dark:border-green-700/50',
|
||||
error: 'bg-red-50 text-red-800 border border-red-300/50 dark:bg-red-950 dark:text-red-200 dark:border-red-700/50',
|
||||
rejected: 'bg-red-50 text-red-800 border border-red-300/50 dark:bg-red-950 dark:text-red-200 dark:border-red-700/50',
|
||||
cancelled: 'bg-gray-50 text-gray-700 border border-gray-300/50 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-600/50',
|
||||
};
|
||||
|
||||
export const isActiveDownloadStatus = (status: ActivityVisualStatus): boolean =>
|
||||
status === 'queued' || status === 'resolving' || status === 'locating' || status === 'downloading';
|
||||
|
||||
const clampPercent = (value: number): number => Math.max(0, Math.min(100, value));
|
||||
|
||||
export const getProgressConfig = (
|
||||
status: ActivityVisualStatus,
|
||||
progress?: number
|
||||
): { percent: number; color: string; animated: boolean } => {
|
||||
switch (status) {
|
||||
case 'queued':
|
||||
return { percent: 5, color: 'bg-amber-600', animated: true };
|
||||
case 'resolving':
|
||||
return { percent: 15, color: 'bg-indigo-600', animated: true };
|
||||
case 'locating':
|
||||
return { percent: 90, color: 'bg-teal-600', animated: true };
|
||||
case 'downloading': {
|
||||
const numericProgress = typeof progress === 'number' ? clampPercent(progress) : 0;
|
||||
return {
|
||||
percent: clampPercent(20 + numericProgress * 0.8),
|
||||
color: 'bg-sky-600',
|
||||
animated: true,
|
||||
};
|
||||
}
|
||||
case 'complete':
|
||||
case 'fulfilled':
|
||||
return { percent: 100, color: 'bg-green-600', animated: false };
|
||||
case 'error':
|
||||
case 'rejected':
|
||||
return { percent: 100, color: 'bg-red-600', animated: false };
|
||||
case 'cancelled':
|
||||
return { percent: 100, color: 'bg-gray-500', animated: false };
|
||||
case 'pending':
|
||||
default:
|
||||
return { percent: 0, color: 'bg-amber-600', animated: false };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { RequestRecord } from '../../types';
|
||||
|
||||
export type ActivityKind = 'download' | 'request';
|
||||
|
||||
export type ActivityVisualStatus =
|
||||
| 'queued'
|
||||
| 'resolving'
|
||||
| 'locating'
|
||||
| 'downloading'
|
||||
| 'complete'
|
||||
| 'error'
|
||||
| 'cancelled'
|
||||
| 'pending'
|
||||
| 'fulfilled'
|
||||
| 'rejected';
|
||||
|
||||
export interface ActivityItem {
|
||||
id: string;
|
||||
kind: ActivityKind;
|
||||
visualStatus: ActivityVisualStatus;
|
||||
|
||||
title: string;
|
||||
author: string;
|
||||
preview?: string;
|
||||
|
||||
metaLine: string;
|
||||
|
||||
statusLabel: string;
|
||||
statusDetail?: string;
|
||||
adminNote?: string;
|
||||
|
||||
progress?: number;
|
||||
progressAnimated?: boolean;
|
||||
sizeRaw?: string;
|
||||
|
||||
timestamp: number;
|
||||
username?: string;
|
||||
|
||||
downloadBookId?: string;
|
||||
downloadPath?: string;
|
||||
|
||||
requestId?: number;
|
||||
requestLevel?: 'book' | 'release';
|
||||
requestNote?: string;
|
||||
requestRecord?: RequestRecord;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export { ActivitySidebar } from './ActivitySidebar';
|
||||
export { ActivityCard } from './ActivityCard';
|
||||
export { ActivityProgressBar } from './ActivityProgressBar';
|
||||
export { downloadToActivityItem, requestToActivityItem } from './activityMappers';
|
||||
export {
|
||||
STATUS_ACCENT_CLASSES,
|
||||
STATUS_BADGE_STYLES,
|
||||
STATUS_LABELS,
|
||||
getProgressConfig,
|
||||
isActiveDownloadStatus,
|
||||
} from './activityStyles';
|
||||
export type { ActivityItem, ActivityKind, ActivityVisualStatus } from './activityTypes';
|
||||
export { ACTIVITY_SIDEBAR_PINNED_STORAGE_KEY } from './ActivitySidebar';
|
||||
@@ -4,6 +4,7 @@ export { SearchBar } from './SearchBar';
|
||||
export { ResultsSection } from './ResultsSection';
|
||||
export { DetailsModal } from './DetailsModal';
|
||||
export { DownloadsSidebar } from './DownloadsSidebar';
|
||||
export { ActivitySidebar } from './activity';
|
||||
export { ToastContainer } from './ToastContainer';
|
||||
export { Footer } from './Footer';
|
||||
export { CardView } from './resultsViews/CardView';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
SettingsTab,
|
||||
SettingsField,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
HeadingFieldConfig,
|
||||
ShowWhenCondition,
|
||||
TableFieldConfig,
|
||||
CustomComponentFieldConfig,
|
||||
} from '../../types/settings';
|
||||
import { FieldWrapper, SettingsSaveBar } from './shared';
|
||||
import {
|
||||
@@ -31,6 +32,11 @@ import {
|
||||
HeadingField,
|
||||
TableField,
|
||||
} from './fields';
|
||||
import {
|
||||
CustomSettingsFieldLayout,
|
||||
getCustomSettingsFieldLayout,
|
||||
renderCustomSettingsField,
|
||||
} from './customFields';
|
||||
|
||||
interface SettingsContentProps {
|
||||
tab: SettingsTab;
|
||||
@@ -43,6 +49,10 @@ interface SettingsContentProps {
|
||||
isUniversalMode?: boolean; // Whether app is in Universal search mode
|
||||
overrideSummary?: Record<string, { count: number; users: Array<{ userId: number; username: string; value: unknown }> }>;
|
||||
embedded?: boolean;
|
||||
customFieldContext?: {
|
||||
authMode?: string;
|
||||
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
|
||||
};
|
||||
}
|
||||
|
||||
function evaluateShowWhenCondition(
|
||||
@@ -69,6 +79,10 @@ function isFieldVisible(
|
||||
values: Record<string, unknown>,
|
||||
isUniversalMode: boolean
|
||||
): boolean {
|
||||
if ('hiddenInUi' in field && field.hiddenInUi) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check universalOnly - hide these fields in Direct mode
|
||||
if ('universalOnly' in field && field.universalOnly && !isUniversalMode) {
|
||||
return false;
|
||||
@@ -134,7 +148,8 @@ const renderField = (
|
||||
onChange: (value: unknown) => void,
|
||||
onAction: () => Promise<ActionResult>,
|
||||
isDisabled: boolean,
|
||||
allValues: Record<string, unknown> // All form values for cascading dropdown support
|
||||
allValues: Record<string, unknown>, // All form values for cascading dropdown support
|
||||
authMode?: string
|
||||
) => {
|
||||
switch (field.type) {
|
||||
case 'TextField':
|
||||
@@ -232,7 +247,27 @@ const renderField = (
|
||||
/>
|
||||
);
|
||||
case 'HeadingField':
|
||||
return <HeadingField field={field as HeadingFieldConfig} />;
|
||||
{
|
||||
const headingField = field as HeadingFieldConfig;
|
||||
const normalizedAuthMode = String(authMode || '').toLowerCase();
|
||||
const dynamicDescription = headingField.descriptionByAuthMode
|
||||
? (
|
||||
headingField.descriptionByAuthMode[normalizedAuthMode]
|
||||
?? headingField.descriptionByAuthMode.default
|
||||
?? headingField.descriptionByAuthMode.none
|
||||
?? headingField.description
|
||||
)
|
||||
: headingField.description;
|
||||
|
||||
return (
|
||||
<HeadingField
|
||||
field={{
|
||||
...headingField,
|
||||
description: dynamicDescription,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return <div>Unknown field type</div>;
|
||||
}
|
||||
@@ -249,11 +284,13 @@ export const SettingsContent = ({
|
||||
isUniversalMode = true,
|
||||
overrideSummary,
|
||||
embedded = false,
|
||||
customFieldContext,
|
||||
}: SettingsContentProps) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [customFieldUiState, setCustomFieldUiState] = useState<Record<string, Record<string, unknown>>>({});
|
||||
|
||||
// Reset scroll position when tab changes
|
||||
useEffect(() => {
|
||||
// Reset scroll position when tab changes before paint.
|
||||
useLayoutEffect(() => {
|
||||
if (embedded) {
|
||||
return;
|
||||
}
|
||||
@@ -262,17 +299,135 @@ export const SettingsContent = ({
|
||||
}
|
||||
}, [embedded, tab.name]);
|
||||
|
||||
useEffect(() => {
|
||||
setCustomFieldUiState({});
|
||||
}, [tab.name]);
|
||||
|
||||
const updateCustomFieldUiState = useCallback((fieldKey: string, key: string, value: unknown) => {
|
||||
setCustomFieldUiState((prev) => {
|
||||
const previousFieldState = prev[fieldKey] || {};
|
||||
if (previousFieldState[key] === value) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[fieldKey]: {
|
||||
...previousFieldState,
|
||||
[key]: value,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Memoize the visible fields to avoid recalculating on every render
|
||||
const visibleFields = useMemo(
|
||||
const baseVisibleFields = useMemo(
|
||||
() => tab.fields.filter((field) => isFieldVisible(field, values, isUniversalMode)),
|
||||
[tab.fields, values, isUniversalMode]
|
||||
);
|
||||
|
||||
const customFieldLayouts = useMemo(() => {
|
||||
const layouts: Record<string, CustomSettingsFieldLayout> = {};
|
||||
baseVisibleFields.forEach((field) => {
|
||||
if (field.type !== 'CustomComponentField') {
|
||||
return;
|
||||
}
|
||||
layouts[field.key] = getCustomSettingsFieldLayout({
|
||||
field: field as CustomComponentFieldConfig,
|
||||
tab,
|
||||
values,
|
||||
uiState: customFieldUiState[field.key] || {},
|
||||
});
|
||||
});
|
||||
return layouts;
|
||||
}, [baseVisibleFields, tab, values, customFieldUiState]);
|
||||
|
||||
const activeTakeOverFieldKey = useMemo(() => {
|
||||
for (const field of baseVisibleFields) {
|
||||
if (field.type !== 'CustomComponentField') {
|
||||
continue;
|
||||
}
|
||||
if (customFieldLayouts[field.key]?.takeOverTab) {
|
||||
return field.key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [baseVisibleFields, customFieldLayouts]);
|
||||
|
||||
// Reset scroll when entering/leaving a custom subpage takeover before paint.
|
||||
useLayoutEffect(() => {
|
||||
if (embedded) {
|
||||
return;
|
||||
}
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = 0;
|
||||
}
|
||||
}, [embedded, activeTakeOverFieldKey]);
|
||||
|
||||
const visibleFields = useMemo(() => {
|
||||
if (!activeTakeOverFieldKey) {
|
||||
return baseVisibleFields;
|
||||
}
|
||||
return baseVisibleFields.filter((field) => field.key === activeTakeOverFieldKey);
|
||||
}, [activeTakeOverFieldKey, baseVisibleFields]);
|
||||
|
||||
const activeTakeOverLayout = activeTakeOverFieldKey
|
||||
? customFieldLayouts[activeTakeOverFieldKey]
|
||||
: undefined;
|
||||
const isTakeOverActive = Boolean(activeTakeOverFieldKey);
|
||||
const customSaveBar = activeTakeOverLayout?.saveBar;
|
||||
|
||||
const saveBarOnSave = isTakeOverActive
|
||||
? customSaveBar?.onSave
|
||||
: onSave;
|
||||
const saveBarIsSaving = isTakeOverActive
|
||||
? Boolean(customSaveBar?.isSaving)
|
||||
: isSaving;
|
||||
const saveBarHasChanges = isTakeOverActive
|
||||
? Boolean(customSaveBar?.hasChanges && customSaveBar?.onSave)
|
||||
: hasChanges;
|
||||
|
||||
const renderedFields = (
|
||||
<div className="space-y-5">
|
||||
{visibleFields.map((field) => {
|
||||
const disabledState = getDisabledState(field, values);
|
||||
const fieldOverrideSummary = overrideSummary?.[field.key];
|
||||
|
||||
const renderedField = field.type === 'CustomComponentField'
|
||||
? renderCustomSettingsField({
|
||||
field: field as CustomComponentFieldConfig,
|
||||
tab,
|
||||
values,
|
||||
onChange,
|
||||
onAction,
|
||||
uiState: customFieldUiState[field.key] || {},
|
||||
onUiStateChange: (key, value) => updateCustomFieldUiState(field.key, key, value),
|
||||
isDisabled: disabledState.disabled,
|
||||
disabledReason: disabledState.reason,
|
||||
authMode: customFieldContext?.authMode,
|
||||
onShowToast: customFieldContext?.onShowToast,
|
||||
})
|
||||
: renderField(
|
||||
field,
|
||||
values[field.key],
|
||||
(v) => onChange(field.key, v),
|
||||
() => onAction(field.key),
|
||||
disabledState.disabled,
|
||||
values,
|
||||
customFieldContext?.authMode
|
||||
);
|
||||
|
||||
const shouldWrapInFieldWrapper = !(
|
||||
field.type === 'CustomComponentField' && !(field as CustomComponentFieldConfig).wrapInFieldWrapper
|
||||
);
|
||||
|
||||
if (!shouldWrapInFieldWrapper) {
|
||||
return (
|
||||
<div key={`${tab.name}-${field.key}`}>
|
||||
{renderedField}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FieldWrapper
|
||||
key={`${tab.name}-${field.key}`}
|
||||
@@ -282,29 +437,22 @@ export const SettingsContent = ({
|
||||
userOverrideCount={fieldOverrideSummary?.count}
|
||||
userOverrideDetails={fieldOverrideSummary?.users}
|
||||
>
|
||||
{renderField(
|
||||
field,
|
||||
values[field.key],
|
||||
(v) => onChange(field.key, v),
|
||||
() => onAction(field.key),
|
||||
disabledState.disabled,
|
||||
values
|
||||
)}
|
||||
{renderedField}
|
||||
</FieldWrapper>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
const saveButton = hasChanges ? (
|
||||
const saveButton = saveBarHasChanges && saveBarOnSave ? (
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
onClick={() => { void saveBarOnSave(); }}
|
||||
disabled={saveBarIsSaving}
|
||||
className="w-full py-2.5 px-4 rounded-lg font-medium transition-colors
|
||||
bg-sky-600 text-white hover:bg-sky-700
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSaving ? (
|
||||
{saveBarIsSaving ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
||||
<circle
|
||||
@@ -345,13 +493,15 @@ export const SettingsContent = ({
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto p-6"
|
||||
style={{ paddingBottom: hasChanges ? 'calc(5rem + env(safe-area-inset-bottom))' : '1.5rem' }}
|
||||
style={{ paddingBottom: saveBarHasChanges ? 'calc(5rem + env(safe-area-inset-bottom))' : '1.5rem' }}
|
||||
>
|
||||
{renderedFields}
|
||||
</div>
|
||||
|
||||
{/* Save button - only visible when there are changes */}
|
||||
{hasChanges && <SettingsSaveBar onSave={onSave} isSaving={isSaving} />}
|
||||
{saveBarHasChanges && saveBarOnSave && (
|
||||
<SettingsSaveBar onSave={saveBarOnSave} isSaving={saveBarIsSaving} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,10 +2,10 @@ import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { useSettings } from '../../hooks/useSettings';
|
||||
import { useSearchMode } from '../../contexts/SearchModeContext';
|
||||
import { getAdminSettingsOverridesSummary, getSettingsTab } from '../../services/api';
|
||||
import { primeUsersCache } from './users/useUsersFetch';
|
||||
import { SettingsHeader } from './SettingsHeader';
|
||||
import { SettingsSidebar } from './SettingsSidebar';
|
||||
import { SettingsContent } from './SettingsContent';
|
||||
import { UsersPanel } from './UsersPanel';
|
||||
|
||||
interface SettingsModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -98,6 +98,7 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
|
||||
setShowMobileDetail(false);
|
||||
setIsClosing(false);
|
||||
setTabOverrideSummaries({});
|
||||
void primeUsersCache();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
@@ -129,7 +130,7 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
|
||||
}, [isOpen, selectedTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !selectedTab || selectedTab === 'users') {
|
||||
if (!isOpen || !selectedTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -266,19 +267,7 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
|
||||
const selectedAuthMethod = values.security?.AUTH_METHOD;
|
||||
const usersAuthMode = typeof selectedAuthMethod === 'string' ? selectedAuthMethod : authMode;
|
||||
const currentTabContent = currentTab
|
||||
? (selectedTab === 'users' ? (
|
||||
<UsersPanel
|
||||
authMode={usersAuthMode}
|
||||
tab={currentTab}
|
||||
values={values[currentTab.name] || {}}
|
||||
onChange={handleFieldChange}
|
||||
onSave={handleSave}
|
||||
onAction={handleAction}
|
||||
isSaving={isSaving}
|
||||
hasChanges={currentTabHasChanges}
|
||||
onShowToast={onShowToast}
|
||||
/>
|
||||
) : (selectedTab === 'security' && securityAccessError) ? (
|
||||
? ((selectedTab === 'security' && securityAccessError) ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 gap-3">
|
||||
<p className="text-sm opacity-60">{securityAccessError}</p>
|
||||
</div>
|
||||
@@ -293,6 +282,10 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
|
||||
hasChanges={currentTabHasChanges}
|
||||
isUniversalMode={isUniversalMode}
|
||||
overrideSummary={tabOverrideSummaries[currentTab.name]}
|
||||
customFieldContext={{
|
||||
authMode: usersAuthMode,
|
||||
onShowToast,
|
||||
}}
|
||||
/>
|
||||
))
|
||||
: null;
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { AdminUser } from '../../services/api';
|
||||
import { ActionResult, SettingsTab } from '../../types/settings';
|
||||
import {
|
||||
canCreateLocalUsersForAuthMode,
|
||||
UserListView,
|
||||
UserOverridesView,
|
||||
useUserForm,
|
||||
useUserMutations,
|
||||
useUsersFetch,
|
||||
useUsersPanelState,
|
||||
} from './users';
|
||||
import { SettingsContent } from './SettingsContent';
|
||||
import { SettingsSaveBar } from './shared';
|
||||
|
||||
interface UsersPanelProps {
|
||||
authMode: string;
|
||||
tab: SettingsTab;
|
||||
values: Record<string, unknown>;
|
||||
onChange: (key: string, value: unknown) => void;
|
||||
onSave: () => Promise<void>;
|
||||
onAction: (key: string) => Promise<ActionResult>;
|
||||
isSaving: boolean;
|
||||
hasChanges: boolean;
|
||||
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
|
||||
}
|
||||
|
||||
export const UsersPanel = ({
|
||||
authMode,
|
||||
tab,
|
||||
values,
|
||||
onChange,
|
||||
onSave,
|
||||
onAction,
|
||||
isSaving,
|
||||
hasChanges,
|
||||
onShowToast,
|
||||
}: UsersPanelProps) => {
|
||||
const { route, openCreate, openEdit, openEditOverrides, backToList } = useUsersPanelState();
|
||||
|
||||
const {
|
||||
users,
|
||||
loading,
|
||||
loadError,
|
||||
fetchUsers,
|
||||
fetchUserEditContext,
|
||||
} = useUsersFetch({ onShowToast });
|
||||
|
||||
const {
|
||||
createForm,
|
||||
setCreateForm,
|
||||
resetCreateForm,
|
||||
editingUser,
|
||||
setEditingUser,
|
||||
editPassword,
|
||||
setEditPassword,
|
||||
editPasswordConfirm,
|
||||
setEditPasswordConfirm,
|
||||
downloadDefaults,
|
||||
deliveryPreferences,
|
||||
isUserOverridable,
|
||||
userSettings,
|
||||
setUserSettings,
|
||||
hasUserSettingsChanges,
|
||||
beginEditing,
|
||||
applyUserEditContext,
|
||||
resetEditContext,
|
||||
clearEditState,
|
||||
userOverridableSettings,
|
||||
} = useUserForm();
|
||||
|
||||
const {
|
||||
creating,
|
||||
saving,
|
||||
deletingUserId,
|
||||
syncingCwa,
|
||||
createUser,
|
||||
saveEditedUser,
|
||||
deleteUser,
|
||||
syncCwaUsers,
|
||||
} = useUserMutations({
|
||||
onShowToast,
|
||||
fetchUsers,
|
||||
users,
|
||||
createForm,
|
||||
resetCreateForm,
|
||||
editingUser,
|
||||
editPassword,
|
||||
editPasswordConfirm,
|
||||
userSettings,
|
||||
userOverridableSettings,
|
||||
deliveryPreferences,
|
||||
onEditSaveSuccess: clearEditState,
|
||||
});
|
||||
|
||||
const startEditing = async (user: AdminUser) => {
|
||||
beginEditing(user);
|
||||
try {
|
||||
const context = await fetchUserEditContext(user.id);
|
||||
applyUserEditContext(context);
|
||||
} catch {
|
||||
resetEditContext();
|
||||
}
|
||||
};
|
||||
|
||||
const canCreateLocalUsers = canCreateLocalUsersForAuthMode(authMode);
|
||||
|
||||
const handleBackToList = () => {
|
||||
clearEditState();
|
||||
backToList();
|
||||
};
|
||||
|
||||
const handleCancelCreate = () => {
|
||||
resetCreateForm();
|
||||
backToList();
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
const ok = await createUser();
|
||||
if (ok) {
|
||||
backToList();
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenOverrides = () => {
|
||||
if (editingUser) {
|
||||
openEditOverrides(editingUser.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = async (user: AdminUser) => {
|
||||
openEdit(user.id);
|
||||
await startEditing(user);
|
||||
};
|
||||
|
||||
const handleSyncCwa = async () => {
|
||||
await syncCwaUsers();
|
||||
};
|
||||
|
||||
const handleBackToEdit = () => {
|
||||
if (editingUser) {
|
||||
openEdit(editingUser.id);
|
||||
return;
|
||||
}
|
||||
backToList();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (route.kind === 'create' && !canCreateLocalUsers) {
|
||||
backToList();
|
||||
}
|
||||
}, [backToList, canCreateLocalUsers, route.kind]);
|
||||
|
||||
const handleSaveUserEdit = async () => {
|
||||
const ok = await saveEditedUser({ includeSettings: false });
|
||||
if (ok) {
|
||||
backToList();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveUserOverrides = async () => {
|
||||
const ok = await saveEditedUser({
|
||||
includeProfile: false,
|
||||
includePassword: false,
|
||||
includeSettings: true,
|
||||
});
|
||||
if (ok) {
|
||||
backToList();
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-sm opacity-60 p-8">
|
||||
Loading users...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 gap-3">
|
||||
<p className="text-sm opacity-60">{loadError}</p>
|
||||
<button
|
||||
onClick={fetchUsers}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
|
||||
bg-[var(--bg-soft)] hover:bg-[var(--hover-surface)] transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (route.kind === 'edit-overrides') {
|
||||
if (!editingUser || editingUser.id !== route.userId) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-sm opacity-60 p-8">
|
||||
Loading user details...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<UserOverridesView
|
||||
hasChanges={hasUserSettingsChanges}
|
||||
onBack={handleBackToEdit}
|
||||
deliveryPreferences={deliveryPreferences}
|
||||
isUserOverridable={isUserOverridable}
|
||||
userSettings={userSettings}
|
||||
setUserSettings={(updater) => setUserSettings(updater)}
|
||||
/>
|
||||
|
||||
{hasUserSettingsChanges && (
|
||||
<SettingsSaveBar onSave={handleSaveUserOverrides} isSaving={saving} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div
|
||||
className="flex-1 overflow-y-auto p-6"
|
||||
style={{ paddingBottom: hasChanges ? 'calc(5rem + env(safe-area-inset-bottom))' : '1.5rem' }}
|
||||
>
|
||||
<div>
|
||||
<UserListView
|
||||
authMode={authMode}
|
||||
users={users}
|
||||
onCreate={openCreate}
|
||||
showCreateForm={route.kind === 'create'}
|
||||
createForm={createForm}
|
||||
onCreateFormChange={setCreateForm}
|
||||
creating={creating}
|
||||
isFirstUser={users.length === 0}
|
||||
onCreateSubmit={handleCreate}
|
||||
onCancelCreate={handleCancelCreate}
|
||||
showEditForm={route.kind === 'edit'}
|
||||
activeEditUserId={route.kind === 'edit' ? route.userId : null}
|
||||
editingUser={route.kind === 'edit' ? editingUser : null}
|
||||
onEditingUserChange={setEditingUser}
|
||||
onEditSave={handleSaveUserEdit}
|
||||
saving={saving}
|
||||
onCancelEdit={handleBackToList}
|
||||
editPassword={editPassword}
|
||||
onEditPasswordChange={setEditPassword}
|
||||
editPasswordConfirm={editPasswordConfirm}
|
||||
onEditPasswordConfirmChange={setEditPasswordConfirm}
|
||||
downloadDefaults={downloadDefaults}
|
||||
onOpenOverrides={handleOpenOverrides}
|
||||
onEdit={handleEdit}
|
||||
onDelete={deleteUser}
|
||||
deletingUserId={deletingUserId}
|
||||
onSyncCwa={handleSyncCwa}
|
||||
syncingCwa={syncingCwa}
|
||||
/>
|
||||
|
||||
<div className="pt-5 mt-4 border-t border-black/10 dark:border-white/10">
|
||||
<SettingsContent
|
||||
tab={tab}
|
||||
values={values}
|
||||
onChange={onChange}
|
||||
onSave={onSave}
|
||||
onAction={onAction}
|
||||
isSaving={isSaving}
|
||||
hasChanges={false}
|
||||
embedded
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasChanges && (
|
||||
<SettingsSaveBar onSave={onSave} isSaving={isSaving} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useMemo } from 'react';
|
||||
import { SelectFieldConfig, TableFieldConfig } from '../../../types/settings';
|
||||
import type { RequestPolicyContentType, RequestPolicyMode } from '../users';
|
||||
import {
|
||||
RequestPolicyGrid,
|
||||
normalizeRequestPolicyDefaults,
|
||||
normalizeRequestPolicyRules,
|
||||
parseSourceCapabilitiesFromRulesField,
|
||||
} from '../users';
|
||||
import { CustomSettingsFieldRendererProps } from './types';
|
||||
|
||||
export const RequestPolicyGridField = ({
|
||||
field,
|
||||
values,
|
||||
onChange,
|
||||
isDisabled,
|
||||
}: CustomSettingsFieldRendererProps) => {
|
||||
const requestRulesField = useMemo(
|
||||
() =>
|
||||
field.boundFields?.find(
|
||||
(boundField): boundField is TableFieldConfig =>
|
||||
boundField.key === 'REQUEST_POLICY_RULES' && boundField.type === 'TableField'
|
||||
),
|
||||
[field.boundFields]
|
||||
);
|
||||
|
||||
const defaultEbookField = useMemo(
|
||||
() =>
|
||||
field.boundFields?.find(
|
||||
(boundField): boundField is SelectFieldConfig =>
|
||||
boundField.key === 'REQUEST_POLICY_DEFAULT_EBOOK' && boundField.type === 'SelectField'
|
||||
),
|
||||
[field.boundFields]
|
||||
);
|
||||
const defaultAudioField = useMemo(
|
||||
() =>
|
||||
field.boundFields?.find(
|
||||
(boundField): boundField is SelectFieldConfig =>
|
||||
boundField.key === 'REQUEST_POLICY_DEFAULT_AUDIOBOOK' && boundField.type === 'SelectField'
|
||||
),
|
||||
[field.boundFields]
|
||||
);
|
||||
|
||||
if (!requestRulesField) {
|
||||
return (
|
||||
<p className="text-xs opacity-60">
|
||||
Request policy schema is unavailable for this tab.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const globalRequestDefaults = useMemo(
|
||||
() =>
|
||||
normalizeRequestPolicyDefaults({
|
||||
ebook: values.REQUEST_POLICY_DEFAULT_EBOOK,
|
||||
audiobook: values.REQUEST_POLICY_DEFAULT_AUDIOBOOK,
|
||||
}),
|
||||
[values.REQUEST_POLICY_DEFAULT_EBOOK, values.REQUEST_POLICY_DEFAULT_AUDIOBOOK]
|
||||
);
|
||||
|
||||
const explicitGlobalRules = useMemo(
|
||||
() => normalizeRequestPolicyRules(values.REQUEST_POLICY_RULES),
|
||||
[values.REQUEST_POLICY_RULES]
|
||||
);
|
||||
|
||||
const requestSourceCapabilities = useMemo(
|
||||
() =>
|
||||
parseSourceCapabilitiesFromRulesField(
|
||||
requestRulesField,
|
||||
explicitGlobalRules.map((row) => row.source)
|
||||
),
|
||||
[requestRulesField, explicitGlobalRules]
|
||||
);
|
||||
|
||||
const onGlobalDefaultModeChange = (contentType: RequestPolicyContentType, mode: RequestPolicyMode) => {
|
||||
const key =
|
||||
contentType === 'ebook' ? 'REQUEST_POLICY_DEFAULT_EBOOK' : 'REQUEST_POLICY_DEFAULT_AUDIOBOOK';
|
||||
onChange(key, mode);
|
||||
};
|
||||
|
||||
const onGlobalRulesChange = (
|
||||
rules: Array<{
|
||||
source: string;
|
||||
content_type: 'ebook' | 'audiobook';
|
||||
mode: 'download' | 'request_release' | 'blocked';
|
||||
}>
|
||||
) => {
|
||||
onChange('REQUEST_POLICY_RULES', rules);
|
||||
};
|
||||
|
||||
return (
|
||||
<RequestPolicyGrid
|
||||
defaultModes={globalRequestDefaults}
|
||||
onDefaultModeChange={onGlobalDefaultModeChange}
|
||||
defaultModeDisabled={{
|
||||
ebook: isDisabled || Boolean(defaultEbookField?.fromEnv),
|
||||
audiobook: isDisabled || Boolean(defaultAudioField?.fromEnv),
|
||||
}}
|
||||
explicitRules={explicitGlobalRules}
|
||||
onExplicitRulesChange={onGlobalRulesChange}
|
||||
sourceCapabilities={requestSourceCapabilities}
|
||||
rulesDisabled={isDisabled || Boolean(requestRulesField.fromEnv)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import { AdminUser } from '../../../services/api';
|
||||
import { CustomSettingsFieldRendererProps } from './types';
|
||||
import {
|
||||
canCreateLocalUsersForAuthMode,
|
||||
UserListView,
|
||||
UserOverridesView,
|
||||
useUserForm,
|
||||
useUserMutations,
|
||||
useUsersFetch,
|
||||
useUsersPanelState,
|
||||
} from '../users';
|
||||
|
||||
export const UsersManagementField = ({
|
||||
tab: usersTab,
|
||||
values,
|
||||
onUiStateChange,
|
||||
authMode,
|
||||
onShowToast,
|
||||
}: CustomSettingsFieldRendererProps) => {
|
||||
const { route, openCreate, openEdit, openEditOverrides, backToList } = useUsersPanelState();
|
||||
|
||||
const {
|
||||
users,
|
||||
loading,
|
||||
loadError,
|
||||
fetchUsers,
|
||||
fetchUserEditContext,
|
||||
} = useUsersFetch({ onShowToast });
|
||||
|
||||
const {
|
||||
createForm,
|
||||
setCreateForm,
|
||||
resetCreateForm,
|
||||
editingUser,
|
||||
setEditingUser,
|
||||
editPassword,
|
||||
setEditPassword,
|
||||
editPasswordConfirm,
|
||||
setEditPasswordConfirm,
|
||||
downloadDefaults,
|
||||
deliveryPreferences,
|
||||
isUserOverridable,
|
||||
userSettings,
|
||||
setUserSettings,
|
||||
hasUserSettingsChanges,
|
||||
beginEditing,
|
||||
applyUserEditContext,
|
||||
resetEditContext,
|
||||
clearEditState,
|
||||
userOverridableSettings,
|
||||
} = useUserForm();
|
||||
|
||||
const {
|
||||
creating,
|
||||
saving,
|
||||
deletingUserId,
|
||||
syncingCwa,
|
||||
createUser,
|
||||
saveEditedUser,
|
||||
deleteUser,
|
||||
syncCwaUsers,
|
||||
} = useUserMutations({
|
||||
onShowToast,
|
||||
fetchUsers,
|
||||
users,
|
||||
createForm,
|
||||
resetCreateForm,
|
||||
editingUser,
|
||||
editPassword,
|
||||
editPasswordConfirm,
|
||||
userSettings,
|
||||
userOverridableSettings,
|
||||
deliveryPreferences,
|
||||
onEditSaveSuccess: clearEditState,
|
||||
});
|
||||
|
||||
const startEditing = async (user: AdminUser) => {
|
||||
beginEditing(user);
|
||||
try {
|
||||
const context = await fetchUserEditContext(user.id);
|
||||
applyUserEditContext(context);
|
||||
} catch {
|
||||
resetEditContext();
|
||||
}
|
||||
};
|
||||
|
||||
const canCreateLocalUsers = canCreateLocalUsersForAuthMode(authMode || 'none');
|
||||
|
||||
const handleBackToList = () => {
|
||||
onUiStateChange('routeKind', 'list');
|
||||
clearEditState();
|
||||
backToList();
|
||||
};
|
||||
|
||||
const handleCancelCreate = () => {
|
||||
onUiStateChange('routeKind', 'list');
|
||||
resetCreateForm();
|
||||
backToList();
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
const ok = await createUser();
|
||||
if (ok) {
|
||||
backToList();
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenOverrides = () => {
|
||||
if (editingUser) {
|
||||
onUiStateChange('routeKind', 'edit-overrides');
|
||||
openEditOverrides(editingUser.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = async (user: AdminUser) => {
|
||||
onUiStateChange('routeKind', 'edit');
|
||||
openEdit(user.id);
|
||||
await startEditing(user);
|
||||
};
|
||||
|
||||
const handleSyncCwa = async () => {
|
||||
await syncCwaUsers();
|
||||
};
|
||||
|
||||
const handleBackToEdit = () => {
|
||||
if (editingUser) {
|
||||
onUiStateChange('routeKind', 'edit');
|
||||
openEdit(editingUser.id);
|
||||
return;
|
||||
}
|
||||
onUiStateChange('routeKind', 'list');
|
||||
backToList();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (route.kind === 'create' && !canCreateLocalUsers) {
|
||||
backToList();
|
||||
}
|
||||
}, [backToList, canCreateLocalUsers, route.kind]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
onUiStateChange('routeKind', route.kind);
|
||||
}, [onUiStateChange, route.kind]);
|
||||
|
||||
const handleSaveUserEdit = useCallback(async () => {
|
||||
const ok = await saveEditedUser({ includeSettings: false });
|
||||
if (ok) {
|
||||
backToList();
|
||||
}
|
||||
}, [backToList, saveEditedUser]);
|
||||
|
||||
const handleSaveUserOverrides = useCallback(async () => {
|
||||
const ok = await saveEditedUser({
|
||||
includeProfile: false,
|
||||
includePassword: false,
|
||||
includeSettings: true,
|
||||
});
|
||||
if (ok) {
|
||||
backToList();
|
||||
}
|
||||
}, [backToList, saveEditedUser]);
|
||||
|
||||
const handleSaveUserOverridesRef = useRef(handleSaveUserOverrides);
|
||||
useEffect(() => {
|
||||
handleSaveUserOverridesRef.current = handleSaveUserOverrides;
|
||||
}, [handleSaveUserOverrides]);
|
||||
|
||||
const triggerSaveUserOverrides = useCallback(async () => {
|
||||
await handleSaveUserOverridesRef.current();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (route.kind !== 'edit-overrides') {
|
||||
onUiStateChange('hasChanges', false);
|
||||
onUiStateChange('isSaving', false);
|
||||
onUiStateChange('onSave', undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
onUiStateChange('hasChanges', hasUserSettingsChanges);
|
||||
onUiStateChange('isSaving', saving);
|
||||
onUiStateChange('onSave', triggerSaveUserOverrides);
|
||||
}, [hasUserSettingsChanges, onUiStateChange, route.kind, saving, triggerSaveUserOverrides]);
|
||||
|
||||
if (route.kind === 'edit-overrides') {
|
||||
if (!editingUser || editingUser.id !== route.userId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center text-sm opacity-60 py-8">
|
||||
Loading user details...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UserOverridesView
|
||||
embedded
|
||||
hasChanges={hasUserSettingsChanges}
|
||||
onBack={handleBackToEdit}
|
||||
deliveryPreferences={deliveryPreferences}
|
||||
isUserOverridable={isUserOverridable}
|
||||
userSettings={userSettings}
|
||||
setUserSettings={(updater) => setUserSettings(updater)}
|
||||
usersTab={usersTab}
|
||||
globalUsersSettingsValues={values}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UserListView
|
||||
authMode={authMode || 'none'}
|
||||
users={users}
|
||||
loadingUsers={loading}
|
||||
loadError={loadError}
|
||||
onRetryLoadUsers={() => void fetchUsers()}
|
||||
onCreate={openCreate}
|
||||
showCreateForm={route.kind === 'create'}
|
||||
createForm={createForm}
|
||||
onCreateFormChange={setCreateForm}
|
||||
creating={creating}
|
||||
isFirstUser={users.length === 0}
|
||||
onCreateSubmit={handleCreate}
|
||||
onCancelCreate={handleCancelCreate}
|
||||
showEditForm={route.kind === 'edit'}
|
||||
activeEditUserId={route.kind === 'edit' ? route.userId : null}
|
||||
editingUser={route.kind === 'edit' ? editingUser : null}
|
||||
onEditingUserChange={setEditingUser}
|
||||
onEditSave={handleSaveUserEdit}
|
||||
saving={saving}
|
||||
onCancelEdit={handleBackToList}
|
||||
editPassword={editPassword}
|
||||
onEditPasswordChange={setEditPassword}
|
||||
editPasswordConfirm={editPasswordConfirm}
|
||||
onEditPasswordConfirmChange={setEditPasswordConfirm}
|
||||
downloadDefaults={downloadDefaults}
|
||||
onOpenOverrides={handleOpenOverrides}
|
||||
onEdit={handleEdit}
|
||||
onDelete={deleteUser}
|
||||
deletingUserId={deletingUserId}
|
||||
onSyncCwa={handleSyncCwa}
|
||||
syncingCwa={syncingCwa}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ComponentType, ReactNode } from 'react';
|
||||
import { RequestPolicyGridField } from './RequestPolicyGridField';
|
||||
import { UsersManagementField } from './UsersManagementField';
|
||||
import {
|
||||
CustomSettingsFieldLayout,
|
||||
CustomSettingsFieldLayoutContext,
|
||||
CustomSettingsFieldRendererProps,
|
||||
} from './types';
|
||||
|
||||
type CustomFieldRenderer = ComponentType<CustomSettingsFieldRendererProps>;
|
||||
type CustomFieldLayoutResolver = (context: CustomSettingsFieldLayoutContext) => CustomSettingsFieldLayout;
|
||||
|
||||
interface CustomFieldDefinition {
|
||||
renderer: CustomFieldRenderer;
|
||||
getLayout?: CustomFieldLayoutResolver;
|
||||
}
|
||||
|
||||
const CUSTOM_FIELD_DEFINITIONS: Record<string, CustomFieldDefinition> = {
|
||||
users_management: {
|
||||
renderer: UsersManagementField,
|
||||
getLayout: ({ uiState }) => {
|
||||
const routeKind = typeof uiState.routeKind === 'string' ? uiState.routeKind : 'list';
|
||||
const isSubpage = routeKind === 'edit-overrides';
|
||||
const onSave = typeof uiState.onSave === 'function'
|
||||
? (uiState.onSave as () => void | Promise<void>)
|
||||
: undefined;
|
||||
return {
|
||||
takeOverTab: isSubpage,
|
||||
saveBar: isSubpage
|
||||
? {
|
||||
hasChanges: Boolean(uiState.hasChanges),
|
||||
isSaving: Boolean(uiState.isSaving),
|
||||
onSave,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
},
|
||||
request_policy_grid: {
|
||||
renderer: RequestPolicyGridField,
|
||||
},
|
||||
};
|
||||
|
||||
export const renderCustomSettingsField = (
|
||||
props: CustomSettingsFieldRendererProps
|
||||
): ReactNode => {
|
||||
const definition = CUSTOM_FIELD_DEFINITIONS[props.field.component];
|
||||
const Renderer = definition?.renderer;
|
||||
if (!Renderer) {
|
||||
return (
|
||||
<p className="text-xs opacity-60">
|
||||
Unknown custom settings component: {props.field.component}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return <Renderer {...props} />;
|
||||
};
|
||||
|
||||
export const getCustomSettingsFieldLayout = (
|
||||
context: CustomSettingsFieldLayoutContext
|
||||
): CustomSettingsFieldLayout => {
|
||||
const definition = CUSTOM_FIELD_DEFINITIONS[context.field.component];
|
||||
if (!definition?.getLayout) {
|
||||
return {};
|
||||
}
|
||||
return definition.getLayout(context);
|
||||
};
|
||||
|
||||
export type {
|
||||
CustomSettingsFieldLayout,
|
||||
CustomSettingsFieldLayoutContext,
|
||||
CustomSettingsFieldRendererProps,
|
||||
} from './types';
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ActionResult, CustomComponentFieldConfig, SettingsTab } from '../../../types/settings';
|
||||
|
||||
export interface CustomSettingsFieldRendererProps {
|
||||
field: CustomComponentFieldConfig;
|
||||
tab: SettingsTab;
|
||||
values: Record<string, unknown>;
|
||||
onChange: (key: string, value: unknown) => void;
|
||||
onAction: (key: string) => Promise<ActionResult>;
|
||||
uiState: Record<string, unknown>;
|
||||
onUiStateChange: (key: string, value: unknown) => void;
|
||||
isDisabled: boolean;
|
||||
disabledReason?: string;
|
||||
authMode?: string;
|
||||
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
|
||||
}
|
||||
|
||||
export interface CustomSettingsFieldLayout {
|
||||
takeOverTab?: boolean;
|
||||
saveBar?: {
|
||||
hasChanges?: boolean;
|
||||
isSaving?: boolean;
|
||||
onSave?: () => void | Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CustomSettingsFieldLayoutContext {
|
||||
field: CustomComponentFieldConfig;
|
||||
tab: SettingsTab;
|
||||
values: Record<string, unknown>;
|
||||
uiState: Record<string, unknown>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, CSSProperties } from 'react';
|
||||
import { useMemo, useEffect, CSSProperties } from 'react';
|
||||
import { TableFieldConfig, TableFieldColumn } from '../../../types/settings';
|
||||
import { DropdownList } from '../../DropdownList';
|
||||
|
||||
@@ -31,6 +31,38 @@ function normalizeRows(rows: Record<string, unknown>[], columns: TableFieldColum
|
||||
});
|
||||
}
|
||||
|
||||
function getFilteredSelectOptions(
|
||||
column: TableFieldColumn,
|
||||
row: Record<string, unknown>
|
||||
): Array<{ value: string; label: string; description?: string; childOf?: string }> {
|
||||
const options = (column.options ?? []).map((opt) => ({
|
||||
value: String(opt.value),
|
||||
label: opt.label ?? String(opt.value),
|
||||
description: opt.description,
|
||||
childOf:
|
||||
opt.childOf === undefined || opt.childOf === null
|
||||
? undefined
|
||||
: String(opt.childOf),
|
||||
}));
|
||||
|
||||
const filterByField = column.filterByField;
|
||||
if (!filterByField) {
|
||||
return options.filter((opt) => !opt.childOf);
|
||||
}
|
||||
|
||||
const rawFilterValue = row[filterByField];
|
||||
const filterValue =
|
||||
rawFilterValue === undefined || rawFilterValue === null || rawFilterValue === ''
|
||||
? undefined
|
||||
: String(rawFilterValue);
|
||||
|
||||
if (!filterValue) {
|
||||
return options.filter((opt) => !opt.childOf);
|
||||
}
|
||||
|
||||
return options.filter((opt) => !opt.childOf || opt.childOf === filterValue);
|
||||
}
|
||||
|
||||
export const TableField = ({ field, value, onChange, disabled }: TableFieldProps) => {
|
||||
const isDisabled = disabled ?? false;
|
||||
|
||||
@@ -68,6 +100,42 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (rows.length === 0) return;
|
||||
|
||||
const nextRows = rows.map((row) => ({ ...row }));
|
||||
let hasChanges = false;
|
||||
|
||||
rows.forEach((row, rowIndex) => {
|
||||
columns.forEach((col) => {
|
||||
if (col.type !== 'select') return;
|
||||
|
||||
const filteredOptions = getFilteredSelectOptions(col, row);
|
||||
const currentValue = String(row[col.key] ?? '');
|
||||
const currentValueIsValid = filteredOptions.some((opt) => opt.value === currentValue);
|
||||
const nonEmptyOptions = filteredOptions.filter((opt) => opt.value !== '');
|
||||
|
||||
if (nonEmptyOptions.length === 1) {
|
||||
const onlyOption = nonEmptyOptions[0].value;
|
||||
if (currentValue !== onlyOption) {
|
||||
nextRows[rowIndex][col.key] = onlyOption;
|
||||
hasChanges = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentValue && !currentValueIsValid) {
|
||||
nextRows[rowIndex][col.key] = '';
|
||||
hasChanges = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (hasChanges) {
|
||||
onChange(nextRows);
|
||||
}
|
||||
}, [rows, columns, onChange]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -129,8 +197,8 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
|
||||
}
|
||||
|
||||
if (col.type === 'select') {
|
||||
const options = (col.options ?? []).map((opt) => ({
|
||||
value: String(opt.value),
|
||||
const options = getFilteredSelectOptions(col, row).map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
description: opt.description,
|
||||
}));
|
||||
|
||||
@@ -82,7 +82,7 @@ export const TagListField = ({ field, value, onChange, disabled, requiredTags }:
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full px-3 py-2 rounded-lg border border-[var(--border-muted)]
|
||||
className={`w-full px-2 py-1 rounded-lg border border-[var(--border-muted)]
|
||||
bg-[var(--bg-soft)] text-sm
|
||||
${isDisabled ? 'opacity-60 cursor-not-allowed' : 'cursor-text'}`}
|
||||
onClick={() => {
|
||||
@@ -90,11 +90,11 @@ export const TagListField = ({ field, value, onChange, disabled, requiredTags }:
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<div className="flex flex-wrap gap-1 items-center">
|
||||
{tags.map((tag, idx) => (
|
||||
<span
|
||||
key={`${tag}-${idx}`}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md
|
||||
border border-[var(--border-muted)] bg-[var(--bg)]
|
||||
max-w-full"
|
||||
title={tag}
|
||||
@@ -107,7 +107,7 @@ export const TagListField = ({ field, value, onChange, disabled, requiredTags }:
|
||||
e.stopPropagation();
|
||||
removeAt(idx);
|
||||
}}
|
||||
className="p-0.5 rounded-full hover:bg-[var(--hover-surface)]"
|
||||
className="p-0.5 rounded hover:bg-[var(--hover-surface)]"
|
||||
aria-label={`Remove ${tag}`}
|
||||
>
|
||||
<svg
|
||||
@@ -144,7 +144,7 @@ export const TagListField = ({ field, value, onChange, disabled, requiredTags }:
|
||||
}}
|
||||
onBlur={() => commitDraft()}
|
||||
placeholder={tags.length === 0 ? field.placeholder : ''}
|
||||
className="flex-1 min-w-[12rem] bg-transparent outline-none py-1"
|
||||
className="flex-1 min-w-[4rem] bg-transparent outline-none px-1 py-0.5"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { DropdownList } from '../../DropdownList';
|
||||
import { RequestPolicyMode } from '../../../types';
|
||||
import {
|
||||
areRuleSetsEqual,
|
||||
getAllowedMatrixModes,
|
||||
getEffectiveCellMode,
|
||||
getInheritedCellMode,
|
||||
isMatrixConfigurable,
|
||||
normalizeExplicitRulesForPersistence,
|
||||
normalizeRequestPolicyRules,
|
||||
REQUEST_POLICY_DEFAULT_OPTIONS,
|
||||
REQUEST_POLICY_MODE_LABELS,
|
||||
RequestPolicyContentType,
|
||||
RequestPolicyDefaultsValue,
|
||||
RequestPolicyRuleRow,
|
||||
RequestPolicySourceCapability,
|
||||
} from './requestPolicyGridUtils';
|
||||
|
||||
interface RequestPolicyGridProps {
|
||||
defaultModes: RequestPolicyDefaultsValue;
|
||||
onDefaultModeChange: (contentType: RequestPolicyContentType, mode: RequestPolicyMode) => void;
|
||||
onDefaultModeReset?: (contentType: RequestPolicyContentType) => void;
|
||||
defaultModeOverrides?: Partial<Record<RequestPolicyContentType, boolean>>;
|
||||
defaultModeDisabled?: Partial<Record<RequestPolicyContentType, boolean>>;
|
||||
explicitRules: RequestPolicyRuleRow[];
|
||||
baseRules?: RequestPolicyRuleRow[];
|
||||
onExplicitRulesChange: (rules: RequestPolicyRuleRow[]) => void;
|
||||
sourceCapabilities: RequestPolicySourceCapability[];
|
||||
rulesDisabled?: boolean;
|
||||
showClearOverrides?: boolean;
|
||||
onClearOverrides?: () => void;
|
||||
clearOverridesDisabled?: boolean;
|
||||
}
|
||||
|
||||
const CONTENT_TYPES: RequestPolicyContentType[] = ['ebook', 'audiobook'];
|
||||
|
||||
const formatSourceLabel = (source: string): string => {
|
||||
return source
|
||||
.split('_')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
const toRuleKey = (source: string, contentType: RequestPolicyContentType) => `${source}::${contentType}`;
|
||||
|
||||
const modeDescriptions: Record<RequestPolicyMode, string> = {
|
||||
download: 'Direct downloads allowed.',
|
||||
request_release: 'Specific release requests only.',
|
||||
request_book: 'Book-level requests only.',
|
||||
blocked: 'Unavailable.',
|
||||
};
|
||||
|
||||
export const RequestPolicyGrid = ({
|
||||
defaultModes,
|
||||
onDefaultModeChange,
|
||||
onDefaultModeReset,
|
||||
defaultModeOverrides,
|
||||
defaultModeDisabled,
|
||||
explicitRules,
|
||||
baseRules = [],
|
||||
onExplicitRulesChange,
|
||||
sourceCapabilities,
|
||||
rulesDisabled = false,
|
||||
showClearOverrides = false,
|
||||
onClearOverrides,
|
||||
clearOverridesDisabled = false,
|
||||
}: RequestPolicyGridProps) => {
|
||||
const normalizedExplicitRules = useMemo(
|
||||
() =>
|
||||
normalizeExplicitRulesForPersistence({
|
||||
explicitRules: normalizeRequestPolicyRules(explicitRules),
|
||||
baseRules,
|
||||
defaultModes,
|
||||
sourceCapabilities,
|
||||
}),
|
||||
[explicitRules, baseRules, defaultModes, sourceCapabilities]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!areRuleSetsEqual(normalizedExplicitRules, normalizeRequestPolicyRules(explicitRules))) {
|
||||
onExplicitRulesChange(normalizedExplicitRules);
|
||||
}
|
||||
}, [normalizedExplicitRules, explicitRules, onExplicitRulesChange]);
|
||||
|
||||
const explicitRuleMap = useMemo(() => {
|
||||
const map = new Map<string, RequestPolicyRuleRow>();
|
||||
normalizedExplicitRules.forEach((rule) => {
|
||||
map.set(toRuleKey(rule.source, rule.content_type), rule);
|
||||
});
|
||||
return map;
|
||||
}, [normalizedExplicitRules]);
|
||||
|
||||
const sourceRows = sourceCapabilities.map((sourceCapability) => ({
|
||||
...sourceCapability,
|
||||
displayName: sourceCapability.displayName || formatSourceLabel(sourceCapability.source),
|
||||
}));
|
||||
|
||||
const hasConfigurableColumn = CONTENT_TYPES.some((contentType) =>
|
||||
isMatrixConfigurable(defaultModes[contentType])
|
||||
);
|
||||
|
||||
const updateCellRule = (
|
||||
source: string,
|
||||
contentType: RequestPolicyContentType,
|
||||
nextMode: RequestPolicyMode
|
||||
) => {
|
||||
const inheritedMode = getInheritedCellMode(source, contentType, defaultModes, baseRules);
|
||||
const nextExplicitRules = normalizedExplicitRules.filter(
|
||||
(rule) => !(rule.source === source && rule.content_type === contentType)
|
||||
);
|
||||
|
||||
if (nextMode !== inheritedMode) {
|
||||
nextExplicitRules.push({
|
||||
source,
|
||||
content_type: contentType,
|
||||
mode: nextMode as RequestPolicyRuleRow['mode'],
|
||||
});
|
||||
}
|
||||
|
||||
const normalized = normalizeExplicitRulesForPersistence({
|
||||
explicitRules: nextExplicitRules,
|
||||
baseRules,
|
||||
defaultModes,
|
||||
sourceCapabilities,
|
||||
});
|
||||
onExplicitRulesChange(normalized);
|
||||
};
|
||||
|
||||
const resetCellRule = (source: string, contentType: RequestPolicyContentType) => {
|
||||
const nextRules = normalizedExplicitRules.filter(
|
||||
(rule) => !(rule.source === source && rule.content_type === contentType)
|
||||
);
|
||||
onExplicitRulesChange(nextRules);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{showClearOverrides && onClearOverrides && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearOverrides}
|
||||
disabled={clearOverridesDisabled}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-medium border border-[var(--border-muted)] bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Clear all overrides
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-[var(--border-muted)]">
|
||||
{/* Header */}
|
||||
<div className="hidden sm:grid sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_minmax(0,1fr)] gap-3 px-3 py-2 bg-[var(--bg-soft)] text-xs font-medium opacity-60 border-b border-[var(--border-muted)] rounded-t-lg">
|
||||
<span>Source</span>
|
||||
<span>Ebook</span>
|
||||
<span>Audiobook</span>
|
||||
</div>
|
||||
|
||||
{/* Default row */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_minmax(0,1fr)] gap-3 px-3 py-2.5 items-center bg-[var(--bg-soft)] border-b-2 border-[var(--border-muted)]">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold truncate">Default</p>
|
||||
</div>
|
||||
|
||||
{CONTENT_TYPES.map((contentType) => {
|
||||
const mode = defaultModes[contentType];
|
||||
const isOverridden = Boolean(defaultModeOverrides?.[contentType]);
|
||||
const isDisabled = Boolean(defaultModeDisabled?.[contentType]);
|
||||
|
||||
const mobileLabel = (
|
||||
<span className="sm:hidden text-xs font-medium opacity-50 mr-2">
|
||||
{contentType === 'ebook' ? 'Ebook:' : 'Audiobook:'}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={contentType} className="flex items-center gap-1.5">
|
||||
{mobileLabel}
|
||||
{isDisabled ? (
|
||||
<div className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)] bg-[var(--bg)] text-sm opacity-60 cursor-not-allowed">
|
||||
{REQUEST_POLICY_MODE_LABELS[mode]}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`min-w-0 flex-1 ${
|
||||
isOverridden ? 'rounded-lg ring-1 ring-sky-500/40' : ''
|
||||
}`}
|
||||
>
|
||||
<DropdownList
|
||||
options={REQUEST_POLICY_DEFAULT_OPTIONS}
|
||||
value={mode}
|
||||
onChange={(value) =>
|
||||
onDefaultModeChange(
|
||||
contentType,
|
||||
(Array.isArray(value) ? value[0] : value) as RequestPolicyMode
|
||||
)
|
||||
}
|
||||
widthClassName="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isOverridden && onDefaultModeReset && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDefaultModeReset(contentType)}
|
||||
disabled={isDisabled}
|
||||
className="text-xs text-sky-500 hover:text-sky-400 transition-colors shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Source rows */}
|
||||
{hasConfigurableColumn ? (
|
||||
sourceRows.map((sourceRow, index) => (
|
||||
<div
|
||||
key={sourceRow.source}
|
||||
className={`grid grid-cols-1 sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_minmax(0,1fr)] gap-3 px-3 py-2.5 items-center ${
|
||||
index > 0 ? 'border-t border-[var(--border-muted)]' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{sourceRow.displayName}</p>
|
||||
</div>
|
||||
|
||||
{CONTENT_TYPES.map((contentType) => {
|
||||
const key = toRuleKey(sourceRow.source, contentType);
|
||||
const isSupported = sourceRow.supportedContentTypes.includes(contentType);
|
||||
const defaultMode = defaultModes[contentType];
|
||||
const isConfigurable = isMatrixConfigurable(defaultMode);
|
||||
const effectiveMode = getEffectiveCellMode(
|
||||
sourceRow.source,
|
||||
contentType,
|
||||
defaultModes,
|
||||
baseRules,
|
||||
normalizedExplicitRules
|
||||
);
|
||||
const explicitRule = explicitRuleMap.get(key);
|
||||
const isOverridden = Boolean(explicitRule);
|
||||
|
||||
const mobileLabel = (
|
||||
<span className="sm:hidden text-xs font-medium opacity-50 mr-2">
|
||||
{contentType === 'ebook' ? 'Ebook:' : 'Audiobook:'}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (!isSupported) {
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-center min-h-[36px]">
|
||||
{mobileLabel}
|
||||
<span className="text-xs opacity-40">Not supported</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isConfigurable) {
|
||||
return (
|
||||
<div key={key} className="flex items-center min-h-[36px]">
|
||||
{mobileLabel}
|
||||
<span className="text-sm opacity-50">
|
||||
{REQUEST_POLICY_MODE_LABELS[effectiveMode]}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const allowedModes = getAllowedMatrixModes(defaultMode);
|
||||
// When the effective mode isn't an allowed matrix mode (e.g. request_book
|
||||
// as a ceiling default), include it as the first option so the dropdown
|
||||
// shows the current state and lets the user switch away from it.
|
||||
const effectiveModeOption =
|
||||
!allowedModes.includes(effectiveMode as typeof allowedModes[number])
|
||||
? [{ value: effectiveMode, label: REQUEST_POLICY_MODE_LABELS[effectiveMode], description: modeDescriptions[effectiveMode] }]
|
||||
: [];
|
||||
const options = [
|
||||
...effectiveModeOption,
|
||||
...allowedModes.map((mode) => ({
|
||||
value: mode,
|
||||
label: REQUEST_POLICY_MODE_LABELS[mode],
|
||||
description: modeDescriptions[mode],
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-1.5">
|
||||
{mobileLabel}
|
||||
<div
|
||||
className={`min-w-0 flex-1 ${
|
||||
isOverridden ? 'rounded-lg ring-1 ring-sky-500/40' : ''
|
||||
}`}
|
||||
>
|
||||
{rulesDisabled ? (
|
||||
<div className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] text-sm opacity-60 cursor-not-allowed">
|
||||
{REQUEST_POLICY_MODE_LABELS[effectiveMode]}
|
||||
</div>
|
||||
) : (
|
||||
<DropdownList
|
||||
options={options}
|
||||
value={effectiveMode}
|
||||
onChange={(value) => {
|
||||
const nextMode = (Array.isArray(value) ? value[0] : value) as RequestPolicyMode;
|
||||
updateCellRule(sourceRow.source, contentType, nextMode);
|
||||
}}
|
||||
widthClassName="w-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isOverridden && !rulesDisabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetCellRule(sourceRow.source, contentType)}
|
||||
className="text-xs text-sky-500 hover:text-sky-400 transition-colors shrink-0"
|
||||
aria-label={`Reset ${sourceRow.displayName} ${contentType} override`}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="px-3 py-3">
|
||||
<p className="text-xs opacity-60">
|
||||
Per-source overrides are available when a default is set to Download or Request Release.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { AdminUser, DownloadDefaults } from '../../../services/api';
|
||||
import { AdminUser } from '../../../services/api';
|
||||
import { PasswordFieldConfig, SelectFieldConfig, SelectOption, TextFieldConfig } from '../../../types/settings';
|
||||
import { PasswordField, SelectField, TextField } from '../fields';
|
||||
import { FieldWrapper } from '../shared';
|
||||
@@ -17,11 +17,6 @@ const CREATE_ROLE_OPTIONS: SelectOption[] = [
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
];
|
||||
|
||||
const EDIT_ROLE_OPTIONS: SelectOption[] = [
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
{ value: 'user', label: 'User' },
|
||||
];
|
||||
|
||||
const createTextField = (
|
||||
key: string,
|
||||
label: string,
|
||||
@@ -112,10 +107,17 @@ export const UserCreateCard = ({
|
||||
onCancel,
|
||||
}: UserCreateCardProps) => {
|
||||
const usernameField = createTextField('username', 'Username', form.username, 'username', true);
|
||||
const roleField = createRoleField(form.role, CREATE_ROLE_OPTIONS);
|
||||
const displayNameField = createTextField('display_name', 'Display Name', form.display_name, 'Display name');
|
||||
const emailField = createTextField('email', 'Email', form.email, 'user@example.com');
|
||||
const passwordField = createPasswordField('password', 'Password', form.password, 'Min 4 characters', true);
|
||||
const roleField = createRoleField(form.role, CREATE_ROLE_OPTIONS);
|
||||
const confirmPasswordField = createPasswordField(
|
||||
'confirm_password',
|
||||
'Confirm Password',
|
||||
form.password_confirm,
|
||||
'Confirm password',
|
||||
true,
|
||||
);
|
||||
|
||||
return (
|
||||
<UserCardShell title="Create Local User">
|
||||
@@ -127,12 +129,18 @@ export const UserCreateCard = ({
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{renderTextField(usernameField, form.username, (value) => onChange({ ...form, username: value }))}
|
||||
{renderSelectField(roleField, form.role, (value) => onChange({ ...form, role: value }))}
|
||||
{renderTextField(displayNameField, form.display_name, (value) => onChange({ ...form, display_name: value }))}
|
||||
{renderTextField(emailField, form.email, (value) => onChange({ ...form, email: value }))}
|
||||
{renderPasswordField(passwordField, form.password, (value) => onChange({ ...form, password: value }))}
|
||||
</div>
|
||||
|
||||
{renderSelectField(roleField, form.role, (value) => onChange({ ...form, role: value }))}
|
||||
{renderPasswordField(passwordField, form.password, (value) => onChange({ ...form, password: value }))}
|
||||
|
||||
{renderPasswordField(
|
||||
confirmPasswordField,
|
||||
form.password_confirm,
|
||||
(value) => onChange({ ...form, password_confirm: value }),
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button
|
||||
@@ -164,7 +172,6 @@ interface UserEditFieldsProps {
|
||||
onEditPasswordChange: (value: string) => void;
|
||||
editPasswordConfirm: string;
|
||||
onEditPasswordConfirmChange: (value: string) => void;
|
||||
downloadDefaults: DownloadDefaults | null;
|
||||
onDelete?: () => void;
|
||||
onConfirmDelete?: () => void;
|
||||
onCancelDelete?: () => void;
|
||||
@@ -182,7 +189,6 @@ export const UserEditFields = ({
|
||||
onEditPasswordChange,
|
||||
editPasswordConfirm,
|
||||
onEditPasswordConfirmChange,
|
||||
downloadDefaults,
|
||||
onDelete,
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
@@ -190,11 +196,10 @@ export const UserEditFields = ({
|
||||
deleting = false,
|
||||
}: UserEditFieldsProps) => {
|
||||
const capabilities = user.edit_capabilities;
|
||||
const { authSource, canSetPassword, canEditRole, canEditEmail, canEditDisplayName } = capabilities;
|
||||
const { authSource, canSetPassword, canEditEmail, canEditDisplayName } = capabilities;
|
||||
|
||||
const displayNameField = createTextField('display_name', 'Display Name', user.display_name || '', 'Display name');
|
||||
const emailField = createTextField('email', 'Email', user.email || '', 'user@example.com');
|
||||
const roleField = createRoleField(user.role, EDIT_ROLE_OPTIONS);
|
||||
const newPasswordField = createPasswordField('new_password', 'New Password', editPassword, 'Leave empty to keep current');
|
||||
const confirmPasswordField = createPasswordField('confirm_password', 'Confirm Password', editPasswordConfirm, 'Confirm new password', true);
|
||||
|
||||
@@ -208,46 +213,28 @@ export const UserEditFields = ({
|
||||
: 'Email is managed by your identity provider.')
|
||||
: undefined;
|
||||
|
||||
const roleDisabledReason = !canEditRole
|
||||
? (authSource === 'oidc'
|
||||
? (downloadDefaults?.OIDC_ADMIN_GROUP
|
||||
? `Role is managed by the ${downloadDefaults.OIDC_ADMIN_GROUP} group in your identity provider.`
|
||||
: 'Role is managed by OIDC group authorization.')
|
||||
: 'Role is managed by the external authentication source.')
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderTextField(
|
||||
displayNameField,
|
||||
user.display_name || '',
|
||||
(value) => onUserChange({ ...user, display_name: value || null }),
|
||||
!canEditDisplayName,
|
||||
displayNameDisabledReason,
|
||||
)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{renderTextField(
|
||||
displayNameField,
|
||||
user.display_name || '',
|
||||
(value) => onUserChange({ ...user, display_name: value || null }),
|
||||
!canEditDisplayName,
|
||||
displayNameDisabledReason,
|
||||
)}
|
||||
|
||||
{renderTextField(
|
||||
emailField,
|
||||
user.email || '',
|
||||
(value) => onUserChange({ ...user, email: value || null }),
|
||||
!canEditEmail,
|
||||
emailDisabledReason,
|
||||
)}
|
||||
|
||||
{renderSelectField(
|
||||
roleField,
|
||||
user.role,
|
||||
(value) => onUserChange({ ...user, role: value }),
|
||||
!canEditRole,
|
||||
roleDisabledReason,
|
||||
)}
|
||||
{renderTextField(
|
||||
emailField,
|
||||
user.email || '',
|
||||
(value) => onUserChange({ ...user, email: value || null }),
|
||||
!canEditEmail,
|
||||
emailDisabledReason,
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canSetPassword && (
|
||||
<>
|
||||
<div className="border-t border-[var(--border-muted)] pt-4">
|
||||
<p className="text-xs font-medium opacity-60 mb-3">Change Password</p>
|
||||
</div>
|
||||
|
||||
{renderPasswordField(newPasswordField, editPassword, onEditPasswordChange)}
|
||||
|
||||
{editPassword && renderPasswordField(confirmPasswordField, editPasswordConfirm, onEditPasswordConfirmChange)}
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import { useState } from 'react';
|
||||
import { AdminUser, DownloadDefaults } from '../../../services/api';
|
||||
import { DropdownList } from '../../DropdownList';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import {
|
||||
canCreateLocalUsersForAuthMode,
|
||||
CreateUserFormState,
|
||||
getUsersHeadingDescriptionForAuthMode,
|
||||
} from './types';
|
||||
import { UserAuthSourceBadge } from './UserAuthSourceBadge';
|
||||
import { UserCreateCard, UserEditFields } from './UserCard';
|
||||
import { HeadingField } from '../fields';
|
||||
import { HeadingFieldConfig } from '../../../types/settings';
|
||||
|
||||
const EDIT_ROLE_OPTIONS = [
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
{ value: 'user', label: 'User' },
|
||||
];
|
||||
|
||||
interface UserListViewProps {
|
||||
authMode: string;
|
||||
users: AdminUser[];
|
||||
loadingUsers: boolean;
|
||||
loadError: string | null;
|
||||
onRetryLoadUsers: () => void;
|
||||
onCreate: () => void;
|
||||
showCreateForm: boolean;
|
||||
createForm: CreateUserFormState;
|
||||
@@ -44,6 +51,9 @@ interface UserListViewProps {
|
||||
export const UserListView = ({
|
||||
authMode,
|
||||
users,
|
||||
loadingUsers,
|
||||
loadError,
|
||||
onRetryLoadUsers,
|
||||
onCreate,
|
||||
showCreateForm,
|
||||
createForm,
|
||||
@@ -75,12 +85,6 @@ export const UserListView = ({
|
||||
const canCreateLocalUsers = canCreateLocalUsersForAuthMode(authMode);
|
||||
const isCwaMode = String(authMode || 'none').toLowerCase() === 'cwa';
|
||||
const toggleButtonClasses = 'p-2 rounded-full hover-action transition-colors text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100';
|
||||
const usersHeading: HeadingFieldConfig = {
|
||||
key: 'users_heading',
|
||||
type: 'HeadingField',
|
||||
title: 'Users',
|
||||
description: getUsersHeadingDescriptionForAuthMode(authMode),
|
||||
};
|
||||
|
||||
const handleDelete = async (userId: number) => {
|
||||
const ok = await onDelete(userId);
|
||||
@@ -91,11 +95,22 @@ export const UserListView = ({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<HeadingField field={usersHeading} />
|
||||
</div>
|
||||
|
||||
{users.length === 0 ? (
|
||||
{(loadingUsers && users.length === 0) ? (
|
||||
<div className="text-center py-8 space-y-2">
|
||||
<p className="text-sm opacity-50">Loading users...</p>
|
||||
</div>
|
||||
) : (loadError && users.length === 0) ? (
|
||||
<div className="text-center py-8 space-y-3">
|
||||
<p className="text-sm opacity-60">{loadError}</p>
|
||||
<button
|
||||
onClick={onRetryLoadUsers}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
|
||||
bg-[var(--bg-soft)] hover:bg-[var(--hover-surface)] transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="text-center py-8 space-y-2">
|
||||
<p className="text-sm opacity-50">No users yet.</p>
|
||||
<p className="text-xs opacity-40">
|
||||
@@ -146,21 +161,53 @@ export const UserListView = ({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center flex-wrap gap-2 shrink-0 sm:justify-end">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-md px-2.5 py-1 text-xs font-medium leading-none
|
||||
${user.role === 'admin' ? 'bg-sky-500/15 text-sky-600 dark:text-sky-400' : 'bg-zinc-500/10 opacity-70'}`}
|
||||
>
|
||||
{roleLabel}
|
||||
</span>
|
||||
{hasLoadedEditUser && editingUser ? (() => {
|
||||
const caps = editingUser.edit_capabilities;
|
||||
const canEditRole = caps.canEditRole;
|
||||
const roleDisabledReason = !canEditRole
|
||||
? (caps.authSource === 'oidc'
|
||||
? (downloadDefaults?.OIDC_ADMIN_GROUP
|
||||
? `Role is managed by the ${downloadDefaults.OIDC_ADMIN_GROUP} group in your identity provider.`
|
||||
: 'Role is managed by OIDC group authorization.')
|
||||
: 'Role is managed by the external authentication source.')
|
||||
: undefined;
|
||||
|
||||
{isEditingRow && (
|
||||
<button
|
||||
onClick={onOpenOverrides}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white
|
||||
bg-sky-600 hover:bg-sky-700 transition-colors"
|
||||
if (!canEditRole) {
|
||||
return (
|
||||
<Tooltip content={roleDisabledReason || 'Role cannot be changed'} position="bottom">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-md px-2.5 py-1 text-xs font-medium leading-none cursor-not-allowed
|
||||
${editingUser.role === 'admin' ? 'bg-sky-500/15 text-sky-600 dark:text-sky-400' : 'bg-zinc-500/10 opacity-70'}`}
|
||||
>
|
||||
{editingUser.role.charAt(0).toUpperCase() + editingUser.role.slice(1)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownList
|
||||
options={EDIT_ROLE_OPTIONS}
|
||||
value={editingUser.role}
|
||||
onChange={(value) => {
|
||||
const val = Array.isArray(value) ? value[0] ?? '' : value;
|
||||
onEditingUserChange({ ...editingUser, role: val });
|
||||
}}
|
||||
widthClassName="w-28"
|
||||
buttonClassName={`!py-1 !px-2.5 !text-xs !font-medium ${
|
||||
editingUser.role === 'admin'
|
||||
? '!bg-sky-500/15 !text-sky-600 dark:!text-sky-400 !border-sky-500/30'
|
||||
: '!bg-zinc-500/10 !opacity-70'
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
})() : (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-md px-2.5 py-1 text-xs font-medium leading-none
|
||||
${user.role === 'admin' ? 'bg-sky-500/15 text-sky-600 dark:text-sky-400' : 'bg-zinc-500/10 opacity-70'}`}
|
||||
>
|
||||
User Preferences
|
||||
</button>
|
||||
{roleLabel}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
@@ -197,23 +244,38 @@ export const UserListView = ({
|
||||
{isEditingRow && (
|
||||
<div className="p-4 space-y-5 bg-[var(--bg)] rounded-b-lg">
|
||||
{hasLoadedEditUser && editingUser ? (
|
||||
<UserEditFields
|
||||
user={editingUser}
|
||||
onUserChange={onEditingUserChange}
|
||||
onSave={onEditSave}
|
||||
saving={saving}
|
||||
onCancel={onCancelEdit}
|
||||
editPassword={editPassword}
|
||||
onEditPasswordChange={onEditPasswordChange}
|
||||
editPasswordConfirm={editPasswordConfirm}
|
||||
onEditPasswordConfirmChange={onEditPasswordConfirmChange}
|
||||
downloadDefaults={downloadDefaults}
|
||||
onDelete={() => setConfirmDelete(user.id)}
|
||||
onConfirmDelete={() => handleDelete(user.id)}
|
||||
onCancelDelete={() => setConfirmDelete(null)}
|
||||
isDeletePending={confirmDelete === user.id}
|
||||
deleting={deletingUserId === user.id}
|
||||
/>
|
||||
<>
|
||||
<div>
|
||||
<label className="text-sm font-medium">User Preferences</label>
|
||||
<p className="text-xs opacity-60 mt-0.5">Override global delivery and request policy settings for this user.</p>
|
||||
<button
|
||||
onClick={onOpenOverrides}
|
||||
className="mt-2 px-4 py-2 rounded-lg text-sm font-medium text-white
|
||||
bg-sky-600 hover:bg-sky-700 transition-colors"
|
||||
>
|
||||
Open User Preferences
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--border-muted)]" />
|
||||
|
||||
<UserEditFields
|
||||
user={editingUser}
|
||||
onUserChange={onEditingUserChange}
|
||||
onSave={onEditSave}
|
||||
saving={saving}
|
||||
onCancel={onCancelEdit}
|
||||
editPassword={editPassword}
|
||||
onEditPasswordChange={onEditPasswordChange}
|
||||
editPasswordConfirm={editPasswordConfirm}
|
||||
onEditPasswordConfirmChange={onEditPasswordConfirmChange}
|
||||
onDelete={() => setConfirmDelete(user.id)}
|
||||
onConfirmDelete={() => handleDelete(user.id)}
|
||||
onCancelDelete={() => setConfirmDelete(null)}
|
||||
isDeletePending={confirmDelete === user.id}
|
||||
deleting={deletingUserId === user.id}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm opacity-60">Loading user details...</div>
|
||||
)}
|
||||
|
||||
@@ -2,25 +2,33 @@ import { DeliveryPreferencesResponse } from '../../../services/api';
|
||||
import { PerUserSettings } from './types';
|
||||
import { SettingsSubpage } from '../shared';
|
||||
import { UserOverridesSection } from './UserOverridesSection';
|
||||
import { SettingsTab } from '../../../types/settings';
|
||||
import { UserRequestPolicyOverridesSection } from './UserRequestPolicyOverridesSection';
|
||||
|
||||
interface UserOverridesViewProps {
|
||||
embedded?: boolean;
|
||||
hasChanges: boolean;
|
||||
onBack: () => void;
|
||||
deliveryPreferences: DeliveryPreferencesResponse | null;
|
||||
isUserOverridable: (key: keyof PerUserSettings) => boolean;
|
||||
userSettings: PerUserSettings;
|
||||
setUserSettings: (updater: (prev: PerUserSettings) => PerUserSettings) => void;
|
||||
usersTab: SettingsTab;
|
||||
globalUsersSettingsValues: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const UserOverridesView = ({
|
||||
embedded = false,
|
||||
hasChanges,
|
||||
onBack,
|
||||
deliveryPreferences,
|
||||
isUserOverridable,
|
||||
userSettings,
|
||||
setUserSettings,
|
||||
}: UserOverridesViewProps) => (
|
||||
<SettingsSubpage hasBottomSaveBar={hasChanges}>
|
||||
usersTab,
|
||||
globalUsersSettingsValues,
|
||||
}: UserOverridesViewProps) => {
|
||||
const content = (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<button
|
||||
@@ -48,6 +56,24 @@ export const UserOverridesView = ({
|
||||
userSettings={userSettings}
|
||||
setUserSettings={setUserSettings}
|
||||
/>
|
||||
|
||||
<UserRequestPolicyOverridesSection
|
||||
usersTab={usersTab}
|
||||
globalUsersSettingsValues={globalUsersSettingsValues}
|
||||
isUserOverridable={isUserOverridable}
|
||||
userSettings={userSettings}
|
||||
setUserSettings={setUserSettings}
|
||||
/>
|
||||
</div>
|
||||
</SettingsSubpage>
|
||||
);
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSubpage hasBottomSaveBar={hasChanges}>
|
||||
{content}
|
||||
</SettingsSubpage>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import {
|
||||
CustomComponentFieldConfig,
|
||||
HeadingFieldConfig,
|
||||
SelectFieldConfig,
|
||||
SettingsTab,
|
||||
TableFieldConfig,
|
||||
} from '../../../types/settings';
|
||||
import { HeadingField } from '../fields';
|
||||
import { PerUserSettings } from './types';
|
||||
import { RequestPolicyGrid } from './RequestPolicyGrid';
|
||||
import {
|
||||
normalizeRequestPolicyDefaults,
|
||||
normalizeRequestPolicyRules,
|
||||
normalizeExplicitRulesForPersistence,
|
||||
parseSourceCapabilitiesFromRulesField,
|
||||
} from './requestPolicyGridUtils';
|
||||
|
||||
interface UserRequestPolicyOverridesSectionProps {
|
||||
usersTab: SettingsTab;
|
||||
globalUsersSettingsValues: Record<string, unknown>;
|
||||
isUserOverridable: (key: keyof PerUserSettings) => boolean;
|
||||
userSettings: PerUserSettings;
|
||||
setUserSettings: (updater: (prev: PerUserSettings) => PerUserSettings) => void;
|
||||
}
|
||||
|
||||
const REQUEST_POLICY_OVERRIDE_KEYS: Array<keyof PerUserSettings> = [
|
||||
'REQUESTS_ENABLED',
|
||||
'REQUEST_POLICY_DEFAULT_EBOOK',
|
||||
'REQUEST_POLICY_DEFAULT_AUDIOBOOK',
|
||||
'REQUEST_POLICY_RULES',
|
||||
'MAX_PENDING_REQUESTS_PER_USER',
|
||||
'REQUESTS_ALLOW_NOTES',
|
||||
];
|
||||
|
||||
const requestPolicyHeading: HeadingFieldConfig = {
|
||||
type: 'HeadingField',
|
||||
key: 'request_policy_overrides_heading',
|
||||
title: 'Request Policy',
|
||||
description: 'User-level request policy overrides. Reset to inherit global policy values.',
|
||||
};
|
||||
|
||||
const hasOwnNonNull = (settings: PerUserSettings, key: keyof PerUserSettings): boolean => {
|
||||
return Object.prototype.hasOwnProperty.call(settings, key) && settings[key] !== null && settings[key] !== undefined;
|
||||
};
|
||||
|
||||
const toBoolean = (value: unknown): boolean => {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
|
||||
}
|
||||
return Boolean(value);
|
||||
};
|
||||
|
||||
export const UserRequestPolicyOverridesSection = ({
|
||||
usersTab,
|
||||
globalUsersSettingsValues,
|
||||
isUserOverridable,
|
||||
userSettings,
|
||||
setUserSettings,
|
||||
}: UserRequestPolicyOverridesSectionProps) => {
|
||||
const requestsEnabledOverridePresent = hasOwnNonNull(userSettings, 'REQUESTS_ENABLED');
|
||||
const effectiveRequestsEnabled = toBoolean(
|
||||
requestsEnabledOverridePresent
|
||||
? userSettings.REQUESTS_ENABLED
|
||||
: globalUsersSettingsValues.REQUESTS_ENABLED
|
||||
);
|
||||
if (!effectiveRequestsEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestPolicyEditorField = usersTab.fields.find(
|
||||
(field): field is CustomComponentFieldConfig =>
|
||||
field.key === 'request_policy_editor' && field.type === 'CustomComponentField'
|
||||
);
|
||||
const rulesField = requestPolicyEditorField?.boundFields?.find(
|
||||
(field): field is TableFieldConfig =>
|
||||
field.key === 'REQUEST_POLICY_RULES' && field.type === 'TableField'
|
||||
);
|
||||
const defaultEbookField = requestPolicyEditorField?.boundFields?.find(
|
||||
(field): field is SelectFieldConfig =>
|
||||
field.key === 'REQUEST_POLICY_DEFAULT_EBOOK' && field.type === 'SelectField'
|
||||
);
|
||||
const defaultAudioField = requestPolicyEditorField?.boundFields?.find(
|
||||
(field): field is SelectFieldConfig =>
|
||||
field.key === 'REQUEST_POLICY_DEFAULT_AUDIOBOOK' && field.type === 'SelectField'
|
||||
);
|
||||
|
||||
if (!rulesField) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canOverrideDefaults =
|
||||
isUserOverridable('REQUEST_POLICY_DEFAULT_EBOOK') &&
|
||||
isUserOverridable('REQUEST_POLICY_DEFAULT_AUDIOBOOK');
|
||||
const canOverrideRules = isUserOverridable('REQUEST_POLICY_RULES');
|
||||
if (!canOverrideDefaults && !canOverrideRules) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const globalDefaults = normalizeRequestPolicyDefaults({
|
||||
ebook: globalUsersSettingsValues.REQUEST_POLICY_DEFAULT_EBOOK,
|
||||
audiobook: globalUsersSettingsValues.REQUEST_POLICY_DEFAULT_AUDIOBOOK,
|
||||
});
|
||||
const globalRules = normalizeRequestPolicyRules(globalUsersSettingsValues.REQUEST_POLICY_RULES);
|
||||
|
||||
const hasUserEbookDefault = hasOwnNonNull(userSettings, 'REQUEST_POLICY_DEFAULT_EBOOK');
|
||||
const hasUserAudiobookDefault = hasOwnNonNull(userSettings, 'REQUEST_POLICY_DEFAULT_AUDIOBOOK');
|
||||
const explicitUserRules = normalizeRequestPolicyRules(userSettings.REQUEST_POLICY_RULES);
|
||||
|
||||
const effectiveDefaults = normalizeRequestPolicyDefaults({
|
||||
ebook: hasUserEbookDefault ? userSettings.REQUEST_POLICY_DEFAULT_EBOOK : globalDefaults.ebook,
|
||||
audiobook: hasUserAudiobookDefault
|
||||
? userSettings.REQUEST_POLICY_DEFAULT_AUDIOBOOK
|
||||
: globalDefaults.audiobook,
|
||||
});
|
||||
|
||||
const sourceCapabilities = parseSourceCapabilitiesFromRulesField(rulesField, [
|
||||
...globalRules.map((row) => row.source),
|
||||
...explicitUserRules.map((row) => row.source),
|
||||
]);
|
||||
|
||||
const setRulesOverride = (nextRulesRaw: typeof explicitUserRules, nextDefaults = effectiveDefaults) => {
|
||||
const normalized = normalizeExplicitRulesForPersistence({
|
||||
explicitRules: nextRulesRaw,
|
||||
baseRules: globalRules,
|
||||
defaultModes: nextDefaults,
|
||||
sourceCapabilities,
|
||||
});
|
||||
|
||||
setUserSettings((prev) => {
|
||||
const next = { ...prev };
|
||||
if (normalized.length === 0) {
|
||||
delete next.REQUEST_POLICY_RULES;
|
||||
} else {
|
||||
next.REQUEST_POLICY_RULES = normalized as unknown as Array<Record<string, unknown>>;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const hasAnyRequestOverrides = REQUEST_POLICY_OVERRIDE_KEYS.some((key) =>
|
||||
hasOwnNonNull(userSettings, key)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="border-t border-[var(--border-muted)]" />
|
||||
<HeadingField field={requestPolicyHeading} />
|
||||
|
||||
<RequestPolicyGrid
|
||||
defaultModes={effectiveDefaults}
|
||||
onDefaultModeChange={(contentType, mode) => {
|
||||
const settingKey =
|
||||
contentType === 'ebook'
|
||||
? ('REQUEST_POLICY_DEFAULT_EBOOK' as const)
|
||||
: ('REQUEST_POLICY_DEFAULT_AUDIOBOOK' as const);
|
||||
const globalDefault = globalDefaults[contentType];
|
||||
|
||||
setUserSettings((prev) => {
|
||||
const next = { ...prev };
|
||||
if (mode === globalDefault) {
|
||||
delete next[settingKey];
|
||||
} else {
|
||||
next[settingKey] = mode;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
const nextDefaults = {
|
||||
...effectiveDefaults,
|
||||
[contentType]: mode,
|
||||
};
|
||||
setRulesOverride(explicitUserRules, nextDefaults);
|
||||
}}
|
||||
onDefaultModeReset={(contentType) => {
|
||||
const settingKey =
|
||||
contentType === 'ebook'
|
||||
? ('REQUEST_POLICY_DEFAULT_EBOOK' as const)
|
||||
: ('REQUEST_POLICY_DEFAULT_AUDIOBOOK' as const);
|
||||
setUserSettings((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[settingKey];
|
||||
return next;
|
||||
});
|
||||
|
||||
const nextDefaults = {
|
||||
...effectiveDefaults,
|
||||
[contentType]: globalDefaults[contentType],
|
||||
};
|
||||
setRulesOverride(explicitUserRules, nextDefaults);
|
||||
}}
|
||||
defaultModeOverrides={{
|
||||
ebook: hasUserEbookDefault,
|
||||
audiobook: hasUserAudiobookDefault,
|
||||
}}
|
||||
defaultModeDisabled={{
|
||||
ebook:
|
||||
!isUserOverridable('REQUEST_POLICY_DEFAULT_EBOOK') ||
|
||||
Boolean(defaultEbookField?.fromEnv),
|
||||
audiobook:
|
||||
!isUserOverridable('REQUEST_POLICY_DEFAULT_AUDIOBOOK') ||
|
||||
Boolean(defaultAudioField?.fromEnv),
|
||||
}}
|
||||
explicitRules={explicitUserRules}
|
||||
baseRules={globalRules}
|
||||
onExplicitRulesChange={(rules) => setRulesOverride(rules)}
|
||||
sourceCapabilities={sourceCapabilities}
|
||||
rulesDisabled={!isUserOverridable('REQUEST_POLICY_RULES')}
|
||||
showClearOverrides
|
||||
clearOverridesDisabled={!hasAnyRequestOverrides}
|
||||
onClearOverrides={() => {
|
||||
setUserSettings((prev) => {
|
||||
const next = { ...prev };
|
||||
REQUEST_POLICY_OVERRIDE_KEYS.forEach((key) => {
|
||||
delete next[key];
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
export { UserAuthSourceBadge } from './UserAuthSourceBadge';
|
||||
export { UserCreateCard, UserEditFields } from './UserCard';
|
||||
export { UserListView } from './UserListView';
|
||||
export { RequestPolicyGrid } from './RequestPolicyGrid';
|
||||
export { UserOverridesSection } from './UserOverridesSection';
|
||||
export { UserOverridesView } from './UserOverridesView';
|
||||
export { useUserForm } from './useUserForm';
|
||||
@@ -8,3 +9,12 @@ export { useUserMutations } from './useUserMutations';
|
||||
export { useUsersFetch } from './useUsersFetch';
|
||||
export { useUsersPanelState } from './useUsersPanelState';
|
||||
export { canCreateLocalUsersForAuthMode, getUsersHeadingDescriptionForAuthMode } from './types';
|
||||
export {
|
||||
normalizeRequestPolicyDefaults,
|
||||
normalizeRequestPolicyRules,
|
||||
parseSourceCapabilitiesFromRulesField,
|
||||
} from './requestPolicyGridUtils';
|
||||
export type {
|
||||
RequestPolicyContentType,
|
||||
} from './requestPolicyGridUtils';
|
||||
export type { RequestPolicyMode } from '../../../types';
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { RequestPolicyMode } from '../../../types';
|
||||
import { TableFieldConfig } from '../../../types/settings';
|
||||
|
||||
export type RequestPolicyContentType = 'ebook' | 'audiobook';
|
||||
export type RequestPolicyMatrixMode = Exclude<RequestPolicyMode, 'request_book'>;
|
||||
|
||||
export interface RequestPolicyRuleRow {
|
||||
source: string;
|
||||
content_type: RequestPolicyContentType;
|
||||
mode: RequestPolicyMatrixMode;
|
||||
}
|
||||
|
||||
export interface RequestPolicyDefaultsValue {
|
||||
ebook: RequestPolicyMode;
|
||||
audiobook: RequestPolicyMode;
|
||||
}
|
||||
|
||||
export interface RequestPolicySourceCapability {
|
||||
source: string;
|
||||
displayName: string;
|
||||
supportedContentTypes: RequestPolicyContentType[];
|
||||
}
|
||||
|
||||
export const REQUEST_POLICY_DEFAULT_OPTIONS: Array<{
|
||||
value: RequestPolicyMode;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
value: 'download',
|
||||
label: 'Download',
|
||||
description: 'Allow direct downloads.',
|
||||
},
|
||||
{
|
||||
value: 'request_release',
|
||||
label: 'Request Release',
|
||||
description: 'Require requesting a specific release.',
|
||||
},
|
||||
{
|
||||
value: 'request_book',
|
||||
label: 'Request Book',
|
||||
description: 'Allow book-level requests only.',
|
||||
},
|
||||
{
|
||||
value: 'blocked',
|
||||
label: 'Blocked',
|
||||
description: 'Block downloads and requests.',
|
||||
},
|
||||
];
|
||||
|
||||
export const REQUEST_POLICY_MODE_LABELS: Record<RequestPolicyMode, string> = {
|
||||
download: 'Download',
|
||||
request_release: 'Request Release',
|
||||
request_book: 'Request Book',
|
||||
blocked: 'Blocked',
|
||||
};
|
||||
|
||||
const MATRIX_MODES: RequestPolicyMatrixMode[] = ['download', 'request_release', 'blocked'];
|
||||
const CONTENT_TYPES: RequestPolicyContentType[] = ['ebook', 'audiobook'];
|
||||
const MODE_RANK: Record<RequestPolicyMode, number> = {
|
||||
download: 0,
|
||||
request_release: 1,
|
||||
request_book: 2,
|
||||
blocked: 3,
|
||||
};
|
||||
|
||||
const normalizeSource = (value: unknown): string => String(value || '').trim().toLowerCase();
|
||||
|
||||
const normalizeContentType = (value: unknown): RequestPolicyContentType | null => {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === 'ebook') return 'ebook';
|
||||
if (normalized === 'audiobook') return 'audiobook';
|
||||
return null;
|
||||
};
|
||||
|
||||
const normalizeMode = (
|
||||
value: unknown,
|
||||
options: readonly RequestPolicyMode[] = ['download', 'request_release', 'request_book', 'blocked']
|
||||
): RequestPolicyMode | null => {
|
||||
const normalized = String(value || '').trim().toLowerCase() as RequestPolicyMode;
|
||||
return options.includes(normalized) ? normalized : null;
|
||||
};
|
||||
|
||||
const toRuleKey = (source: string, contentType: RequestPolicyContentType) => `${source}::${contentType}`;
|
||||
|
||||
export const sortRules = (rules: RequestPolicyRuleRow[]): RequestPolicyRuleRow[] =>
|
||||
[...rules].sort((a, b) => {
|
||||
const sourceCmp = a.source.localeCompare(b.source);
|
||||
if (sourceCmp !== 0) return sourceCmp;
|
||||
return a.content_type.localeCompare(b.content_type);
|
||||
});
|
||||
|
||||
export const normalizeRequestPolicyDefaults = (
|
||||
raw: Partial<Record<RequestPolicyContentType, unknown>>,
|
||||
fallback: RequestPolicyMode = 'download'
|
||||
): RequestPolicyDefaultsValue => {
|
||||
const fallbackMode = normalizeMode(fallback) || 'download';
|
||||
return {
|
||||
ebook: normalizeMode(raw.ebook) || fallbackMode,
|
||||
audiobook: normalizeMode(raw.audiobook) || fallbackMode,
|
||||
};
|
||||
};
|
||||
|
||||
export const normalizeRequestPolicyRules = (rawRules: unknown): RequestPolicyRuleRow[] => {
|
||||
if (!Array.isArray(rawRules)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const byKey = new Map<string, RequestPolicyRuleRow>();
|
||||
rawRules.forEach((rawRule) => {
|
||||
if (!rawRule || typeof rawRule !== 'object') {
|
||||
return;
|
||||
}
|
||||
const row = rawRule as Record<string, unknown>;
|
||||
const source = normalizeSource(row.source);
|
||||
const contentType = normalizeContentType(row.content_type);
|
||||
const mode = normalizeMode(row.mode, MATRIX_MODES) as RequestPolicyMatrixMode | null;
|
||||
if (!source || !contentType || !mode) {
|
||||
return;
|
||||
}
|
||||
byKey.set(toRuleKey(source, contentType), {
|
||||
source,
|
||||
content_type: contentType,
|
||||
mode,
|
||||
});
|
||||
});
|
||||
|
||||
return sortRules([...byKey.values()]);
|
||||
};
|
||||
|
||||
export const capPolicyMode = (mode: RequestPolicyMode, ceiling: RequestPolicyMode): RequestPolicyMode => {
|
||||
return MODE_RANK[mode] < MODE_RANK[ceiling] ? ceiling : mode;
|
||||
};
|
||||
|
||||
export const isMatrixConfigurable = (defaultMode: RequestPolicyMode): boolean => {
|
||||
return defaultMode !== 'blocked';
|
||||
};
|
||||
|
||||
export const getAllowedMatrixModes = (defaultMode: RequestPolicyMode): RequestPolicyMatrixMode[] => {
|
||||
if (!isMatrixConfigurable(defaultMode)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return MATRIX_MODES.filter((mode) => MODE_RANK[mode] >= MODE_RANK[defaultMode]);
|
||||
};
|
||||
|
||||
export const mergeRequestPolicyRuleLayers = (
|
||||
baseRules: RequestPolicyRuleRow[],
|
||||
overrideRules: RequestPolicyRuleRow[]
|
||||
): RequestPolicyRuleRow[] => {
|
||||
const merged = new Map<string, RequestPolicyRuleRow>();
|
||||
baseRules.forEach((rule) => {
|
||||
merged.set(toRuleKey(rule.source, rule.content_type), rule);
|
||||
});
|
||||
overrideRules.forEach((rule) => {
|
||||
merged.set(toRuleKey(rule.source, rule.content_type), rule);
|
||||
});
|
||||
return sortRules([...merged.values()]);
|
||||
};
|
||||
|
||||
const findRule = (
|
||||
rules: RequestPolicyRuleRow[],
|
||||
source: string,
|
||||
contentType: RequestPolicyContentType
|
||||
): RequestPolicyRuleRow | null => {
|
||||
const normalizedSource = normalizeSource(source);
|
||||
return (
|
||||
rules.find(
|
||||
(rule) => rule.source === normalizedSource && rule.content_type === contentType
|
||||
) || null
|
||||
);
|
||||
};
|
||||
|
||||
export const getInheritedCellMode = (
|
||||
source: string,
|
||||
contentType: RequestPolicyContentType,
|
||||
defaultModes: RequestPolicyDefaultsValue,
|
||||
baseRules: RequestPolicyRuleRow[]
|
||||
): RequestPolicyMode => {
|
||||
const ceiling = defaultModes[contentType];
|
||||
const fromRule = findRule(baseRules, source, contentType)?.mode;
|
||||
return capPolicyMode(fromRule || ceiling, ceiling);
|
||||
};
|
||||
|
||||
export const getEffectiveCellMode = (
|
||||
source: string,
|
||||
contentType: RequestPolicyContentType,
|
||||
defaultModes: RequestPolicyDefaultsValue,
|
||||
baseRules: RequestPolicyRuleRow[],
|
||||
explicitRules: RequestPolicyRuleRow[]
|
||||
): RequestPolicyMode => {
|
||||
const ceiling = defaultModes[contentType];
|
||||
const explicit = findRule(explicitRules, source, contentType)?.mode;
|
||||
if (explicit) {
|
||||
return capPolicyMode(explicit, ceiling);
|
||||
}
|
||||
return getInheritedCellMode(source, contentType, defaultModes, baseRules);
|
||||
};
|
||||
|
||||
export const parseSourceCapabilitiesFromRulesField = (
|
||||
rulesField: TableFieldConfig | null | undefined,
|
||||
fallbackSources: string[] = []
|
||||
): RequestPolicySourceCapability[] => {
|
||||
if (!rulesField || !Array.isArray(rulesField.columns)) {
|
||||
return fallbackSources.map((source) => ({
|
||||
source: normalizeSource(source),
|
||||
displayName: source,
|
||||
supportedContentTypes: ['ebook', 'audiobook'],
|
||||
}));
|
||||
}
|
||||
|
||||
const sourceColumn = rulesField.columns.find((column) => column.key === 'source');
|
||||
const contentTypeColumn = rulesField.columns.find((column) => column.key === 'content_type');
|
||||
|
||||
const sourceOptions = sourceColumn?.options ?? [];
|
||||
const contentTypeOptions = contentTypeColumn?.options ?? [];
|
||||
const bySource = new Map<string, RequestPolicySourceCapability>();
|
||||
|
||||
sourceOptions.forEach((option) => {
|
||||
const source = normalizeSource(option.value);
|
||||
if (!source) {
|
||||
return;
|
||||
}
|
||||
bySource.set(source, {
|
||||
source,
|
||||
displayName: option.label || source,
|
||||
supportedContentTypes: [],
|
||||
});
|
||||
});
|
||||
|
||||
contentTypeOptions.forEach((option) => {
|
||||
const source = normalizeSource(option.childOf);
|
||||
const contentType = normalizeContentType(option.value);
|
||||
if (!source || !contentType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = bySource.get(source) || {
|
||||
source,
|
||||
displayName: source,
|
||||
supportedContentTypes: [],
|
||||
};
|
||||
|
||||
if (!existing.supportedContentTypes.includes(contentType)) {
|
||||
existing.supportedContentTypes.push(contentType);
|
||||
}
|
||||
bySource.set(source, existing);
|
||||
});
|
||||
|
||||
fallbackSources.forEach((sourceValue) => {
|
||||
const source = normalizeSource(sourceValue);
|
||||
if (!source || bySource.has(source)) {
|
||||
return;
|
||||
}
|
||||
bySource.set(source, {
|
||||
source,
|
||||
displayName: source,
|
||||
supportedContentTypes: ['ebook', 'audiobook'],
|
||||
});
|
||||
});
|
||||
|
||||
const orderedSources = sourceOptions
|
||||
.map((option) => normalizeSource(option.value))
|
||||
.filter((source) => source && bySource.has(source));
|
||||
const extraSources = [...bySource.keys()].filter((source) => !orderedSources.includes(source));
|
||||
|
||||
return [...orderedSources, ...extraSources].map((source) => {
|
||||
const row = bySource.get(source)!;
|
||||
const supported = CONTENT_TYPES.filter((contentType) =>
|
||||
row.supportedContentTypes.includes(contentType)
|
||||
);
|
||||
return {
|
||||
...row,
|
||||
supportedContentTypes: supported,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const isSourceContentTypeSupported = (
|
||||
sourceCapabilities: RequestPolicySourceCapability[],
|
||||
source: string,
|
||||
contentType: RequestPolicyContentType
|
||||
): boolean => {
|
||||
const normalizedSource = normalizeSource(source);
|
||||
const sourceCapability = sourceCapabilities.find((row) => row.source === normalizedSource);
|
||||
return Boolean(sourceCapability?.supportedContentTypes.includes(contentType));
|
||||
};
|
||||
|
||||
export const normalizeExplicitRulesForPersistence = ({
|
||||
explicitRules,
|
||||
defaultModes,
|
||||
sourceCapabilities,
|
||||
}: {
|
||||
explicitRules: RequestPolicyRuleRow[];
|
||||
/** @deprecated No longer used — kept for call-site compatibility */
|
||||
baseRules?: RequestPolicyRuleRow[];
|
||||
defaultModes: RequestPolicyDefaultsValue;
|
||||
sourceCapabilities: RequestPolicySourceCapability[];
|
||||
}): RequestPolicyRuleRow[] => {
|
||||
const deduped = normalizeRequestPolicyRules(explicitRules);
|
||||
|
||||
const filtered = deduped.filter((rule) => {
|
||||
if (!isSourceContentTypeSupported(sourceCapabilities, rule.source, rule.content_type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const defaultMode = defaultModes[rule.content_type];
|
||||
const allowedModes = getAllowedMatrixModes(defaultMode);
|
||||
if (!allowedModes.includes(rule.mode)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return sortRules(filtered);
|
||||
};
|
||||
|
||||
export const areRuleSetsEqual = (left: RequestPolicyRuleRow[], right: RequestPolicyRuleRow[]): boolean => {
|
||||
const leftSorted = sortRules(left);
|
||||
const rightSorted = sortRules(right);
|
||||
return JSON.stringify(leftSorted) === JSON.stringify(rightSorted);
|
||||
};
|
||||
@@ -13,7 +13,12 @@ export const buildUserSettingsPayload = (
|
||||
userOverridableSettings: Set<string>,
|
||||
deliveryPreferences: DeliveryPreferencesResponse | null,
|
||||
): Record<string, unknown> =>
|
||||
(deliveryPreferences?.keys || [...userOverridableSettings])
|
||||
Array.from(
|
||||
new Set([
|
||||
...(deliveryPreferences?.keys || []),
|
||||
...userOverridableSettings,
|
||||
])
|
||||
)
|
||||
.map(String)
|
||||
.sort()
|
||||
.reduce<Record<string, unknown>>((payload, key) => {
|
||||
|
||||
@@ -8,12 +8,19 @@ export interface PerUserSettings {
|
||||
BOOKLORE_LIBRARY_ID?: string;
|
||||
BOOKLORE_PATH_ID?: string;
|
||||
EMAIL_RECIPIENT?: string;
|
||||
REQUESTS_ENABLED?: boolean;
|
||||
REQUEST_POLICY_DEFAULT_EBOOK?: string;
|
||||
REQUEST_POLICY_DEFAULT_AUDIOBOOK?: string;
|
||||
REQUEST_POLICY_RULES?: Array<Record<string, unknown>>;
|
||||
MAX_PENDING_REQUESTS_PER_USER?: number;
|
||||
REQUESTS_ALLOW_NOTES?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateUserFormState {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
password_confirm: string;
|
||||
display_name: string;
|
||||
role: string;
|
||||
}
|
||||
@@ -22,6 +29,7 @@ export const INITIAL_CREATE_FORM: CreateUserFormState = {
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
password_confirm: '',
|
||||
display_name: '',
|
||||
role: 'user',
|
||||
};
|
||||
|
||||
@@ -70,7 +70,8 @@ export const useUserMutations = ({
|
||||
|
||||
const createUser = async () => {
|
||||
if (!createForm.username || !createForm.password) return fail('Username and password are required');
|
||||
if (createForm.password.length < MIN_PASSWORD_LENGTH) return fail(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`);
|
||||
const createPasswordError = getPasswordError(createForm.password, createForm.password_confirm);
|
||||
if (createPasswordError) return fail(createPasswordError);
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
|
||||
@@ -7,13 +7,54 @@ import {
|
||||
getAdminUser,
|
||||
getAdminUsers,
|
||||
getDownloadDefaults,
|
||||
getSettingsTab,
|
||||
} from '../../../services/api';
|
||||
import { SettingsField } from '../../../types/settings';
|
||||
import { PerUserSettings } from './types';
|
||||
|
||||
interface UseUsersFetchParams {
|
||||
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
|
||||
}
|
||||
|
||||
let cachedUsers: AdminUser[] | null = null;
|
||||
let cachedLoadError: string | null = null;
|
||||
let usersCacheLoadPromise: Promise<AdminUser[]> | null = null;
|
||||
|
||||
const shouldSuppressAccessToast = (message: string): boolean =>
|
||||
message.toLowerCase().includes('admin access required');
|
||||
|
||||
const toLoadErrorMessage = (err: unknown): string =>
|
||||
err instanceof Error ? err.message : 'Failed to load users';
|
||||
|
||||
const loadUsersIntoCache = async (): Promise<AdminUser[]> => {
|
||||
if (cachedUsers !== null) {
|
||||
return cachedUsers;
|
||||
}
|
||||
if (usersCacheLoadPromise) {
|
||||
return usersCacheLoadPromise;
|
||||
}
|
||||
|
||||
usersCacheLoadPromise = getAdminUsers()
|
||||
.then((data) => {
|
||||
cachedUsers = data;
|
||||
cachedLoadError = null;
|
||||
return data;
|
||||
})
|
||||
.finally(() => {
|
||||
usersCacheLoadPromise = null;
|
||||
});
|
||||
|
||||
return usersCacheLoadPromise;
|
||||
};
|
||||
|
||||
export const primeUsersCache = async (): Promise<void> => {
|
||||
try {
|
||||
await loadUsersIntoCache();
|
||||
} catch {
|
||||
// Silent best-effort warmup.
|
||||
}
|
||||
};
|
||||
|
||||
export interface UserEditContext {
|
||||
user: AdminUser;
|
||||
downloadDefaults: DownloadDefaults;
|
||||
@@ -22,23 +63,52 @@ export interface UserEditContext {
|
||||
userOverridableSettings: Set<string>;
|
||||
}
|
||||
|
||||
export const useUsersFetch = ({ onShowToast }: UseUsersFetchParams) => {
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const getUserOverridableKeys = (fields: SettingsField[]): string[] => {
|
||||
const keys: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const shouldSuppressAccessToast = (message: string): boolean =>
|
||||
message.toLowerCase().includes('admin access required');
|
||||
const collect = (candidateFields: SettingsField[]) => {
|
||||
candidateFields.forEach((field) => {
|
||||
if (field.type === 'CustomComponentField') {
|
||||
if (field.boundFields && field.boundFields.length > 0) {
|
||||
collect(field.boundFields);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (field.type === 'HeadingField') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((field as { userOverridable?: boolean }).userOverridable && !seen.has(field.key)) {
|
||||
seen.add(field.key);
|
||||
keys.push(field.key);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
collect(fields);
|
||||
return keys;
|
||||
};
|
||||
|
||||
export const useUsersFetch = ({ onShowToast }: UseUsersFetchParams) => {
|
||||
const [users, setUsers] = useState<AdminUser[]>(() => cachedUsers ?? []);
|
||||
const [loading, setLoading] = useState<boolean>(() => cachedUsers === null);
|
||||
const [loadError, setLoadError] = useState<string | null>(() => cachedLoadError);
|
||||
|
||||
const fetchUsers = useCallback(async (): Promise<AdminUser[]> => {
|
||||
const hasCachedResult = cachedUsers !== null;
|
||||
try {
|
||||
setLoading(true);
|
||||
if (!hasCachedResult) {
|
||||
setLoading(true);
|
||||
}
|
||||
setLoadError(null);
|
||||
const data = await getAdminUsers();
|
||||
const data = await loadUsersIntoCache();
|
||||
setUsers(data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load users';
|
||||
const message = toLoadErrorMessage(err);
|
||||
cachedLoadError = message;
|
||||
setLoadError(message);
|
||||
if (!shouldSuppressAccessToast(message)) {
|
||||
onShowToast?.(message, 'error');
|
||||
@@ -72,6 +142,14 @@ export const useUsersFetch = ({ onShowToast }: UseUsersFetchParams) => {
|
||||
// Delivery preference introspection is best-effort.
|
||||
}
|
||||
|
||||
try {
|
||||
const usersTab = await getSettingsTab('users');
|
||||
const usersOverridableKeys = getUserOverridableKeys(usersTab.fields);
|
||||
usersOverridableKeys.forEach((key) => userOverridableSettings.add(key));
|
||||
} catch {
|
||||
// Users-tab metadata is best-effort; save still validates server-side.
|
||||
}
|
||||
|
||||
return {
|
||||
user: fullUser,
|
||||
downloadDefaults: defaults,
|
||||
|
||||
@@ -7,6 +7,7 @@ interface TooltipProps {
|
||||
position?: 'top' | 'bottom' | 'left' | 'right';
|
||||
delay?: number;
|
||||
className?: string;
|
||||
unstyled?: boolean;
|
||||
}
|
||||
|
||||
export function Tooltip({
|
||||
@@ -15,11 +16,14 @@ export function Tooltip({
|
||||
position = 'top',
|
||||
delay = 200,
|
||||
className = '',
|
||||
unstyled = false,
|
||||
}: TooltipProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null);
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isPlainTextContent = typeof content === 'string' || typeof content === 'number';
|
||||
const spacing = 6;
|
||||
|
||||
const showTooltip = () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
@@ -31,20 +35,20 @@ export function Tooltip({
|
||||
|
||||
switch (position) {
|
||||
case 'top':
|
||||
top = rect.top - 8;
|
||||
top = rect.top - spacing;
|
||||
left = rect.left + rect.width / 2;
|
||||
break;
|
||||
case 'bottom':
|
||||
top = rect.bottom + 8;
|
||||
top = rect.bottom + spacing;
|
||||
left = rect.left + rect.width / 2;
|
||||
break;
|
||||
case 'left':
|
||||
top = rect.top + rect.height / 2;
|
||||
left = rect.left - 8;
|
||||
left = rect.left - spacing;
|
||||
break;
|
||||
case 'right':
|
||||
top = rect.top + rect.height / 2;
|
||||
left = rect.right + 8;
|
||||
left = rect.right + spacing;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -80,6 +84,9 @@ export function Tooltip({
|
||||
left: '-translate-x-full -translate-y-1/2',
|
||||
right: '-translate-y-1/2',
|
||||
}[position];
|
||||
const tooltipSizeClass = isPlainTextContent
|
||||
? 'px-2 py-1 text-[11px] leading-tight rounded-md font-medium'
|
||||
: 'px-2.5 py-2 text-xs rounded-lg';
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -89,21 +96,25 @@ export function Tooltip({
|
||||
onMouseLeave={hideTooltip}
|
||||
onFocusCapture={showTooltip}
|
||||
onBlurCapture={hideTooltip}
|
||||
className="inline-flex"
|
||||
className="inline-flex max-w-full"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{isVisible && coords && createPortal(
|
||||
<div
|
||||
role="tooltip"
|
||||
className={`fixed z-[9999] px-3 py-2 text-xs rounded-lg shadow-lg
|
||||
pointer-events-none ${transformClass} ${className}`}
|
||||
className={`fixed z-[9999] pointer-events-none ${tooltipSizeClass} ${transformClass} ${className}`}
|
||||
style={{
|
||||
top: coords.top,
|
||||
left: coords.left,
|
||||
background: 'var(--bg-soft)',
|
||||
color: 'var(--text)',
|
||||
border: '1px solid var(--border-muted)',
|
||||
...(unstyled ? {} : {
|
||||
background: 'var(--bg)',
|
||||
color: 'var(--text)',
|
||||
border: isPlainTextContent ? 'none' : '1px solid var(--border-muted)',
|
||||
boxShadow: isPlainTextContent
|
||||
? '0 8px 18px rgba(0, 0, 0, 0.28)'
|
||||
: '0 10px 22px rgba(0, 0, 0, 0.28)',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -20,10 +20,8 @@ export const SocketProvider = ({ children }: SocketProviderProps) => {
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// In dev mode (port 5173), connect directly to backend to avoid Vite proxy issues
|
||||
const wsUrl = window.location.port === '5173'
|
||||
? 'http://localhost:8084'
|
||||
: window.location.origin;
|
||||
// Always connect via current origin so dev proxy and session cookies stay aligned.
|
||||
const wsUrl = window.location.origin;
|
||||
const socketPath = withBasePath('/socket.io');
|
||||
|
||||
console.log('SocketProvider: Connecting to', wsUrl);
|
||||
@@ -31,7 +29,7 @@ export const SocketProvider = ({ children }: SocketProviderProps) => {
|
||||
const socket = io(wsUrl, {
|
||||
path: socketPath,
|
||||
transports: ['polling', 'websocket'],
|
||||
withCredentials: false,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { ContentType, RequestPolicyMode, RequestPolicyResponse } from '../types';
|
||||
|
||||
export const DEFAULT_POLICY_TTL_MS = 60_000;
|
||||
|
||||
const MODE_RANK: Record<RequestPolicyMode, number> = {
|
||||
download: 0,
|
||||
request_release: 1,
|
||||
request_book: 2,
|
||||
blocked: 3,
|
||||
};
|
||||
|
||||
const MATRIX_MODES = new Set<RequestPolicyMode>(['download', 'request_release', 'blocked']);
|
||||
|
||||
const capModeToCeiling = (mode: RequestPolicyMode, ceiling: RequestPolicyMode): RequestPolicyMode => {
|
||||
return MODE_RANK[mode] < MODE_RANK[ceiling] ? ceiling : mode;
|
||||
};
|
||||
|
||||
export interface RefreshPolicyOptions {
|
||||
enabled: boolean;
|
||||
isAdmin: boolean;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export const normalizeContentType = (value: ContentType | string): ContentType => {
|
||||
return String(value).trim().toLowerCase() === 'audiobook' ? 'audiobook' : 'ebook';
|
||||
};
|
||||
|
||||
export const normalizeSource = (value: string): string => {
|
||||
const source = String(value || '').trim().toLowerCase();
|
||||
return source || '*';
|
||||
};
|
||||
|
||||
const normalizeRuleSource = (value: unknown): string | null => {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized || normalized === 'any') {
|
||||
return '*';
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const normalizeRuleContentType = (value: unknown): ContentType | '*' | null => {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized || normalized === 'any') {
|
||||
return '*';
|
||||
}
|
||||
if (normalized === '*') {
|
||||
return '*';
|
||||
}
|
||||
return normalizeContentType(normalized);
|
||||
};
|
||||
|
||||
const parseMatrixMode = (value: unknown): RequestPolicyMode | null => {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const normalized = value.trim().toLowerCase() as RequestPolicyMode;
|
||||
if (!MATRIX_MODES.has(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const resolveDefaultModeFromPolicy = (
|
||||
policy: RequestPolicyResponse | null,
|
||||
isAdmin: boolean,
|
||||
contentType: ContentType | string
|
||||
): RequestPolicyMode => {
|
||||
if (isAdmin || policy?.is_admin) {
|
||||
return 'download';
|
||||
}
|
||||
if (!policy || !policy.requests_enabled) {
|
||||
return 'download';
|
||||
}
|
||||
const normalizedContentType = normalizeContentType(contentType);
|
||||
return policy.defaults?.[normalizedContentType] || 'download';
|
||||
};
|
||||
|
||||
export const resolveSourceModeFromPolicy = (
|
||||
policy: RequestPolicyResponse | null,
|
||||
isAdmin: boolean,
|
||||
source: string,
|
||||
contentType: ContentType | string
|
||||
): RequestPolicyMode => {
|
||||
const defaultMode = resolveDefaultModeFromPolicy(policy, isAdmin, contentType);
|
||||
if (defaultMode === 'download' && (isAdmin || !policy || !policy.requests_enabled)) {
|
||||
return 'download';
|
||||
}
|
||||
|
||||
const normalizedSource = normalizeSource(source);
|
||||
const normalizedContentType = normalizeContentType(contentType);
|
||||
const sourceModes = policy?.source_modes?.find(
|
||||
(sourceMode) => normalizeSource(sourceMode.source) === normalizedSource
|
||||
);
|
||||
const fromSource = sourceModes?.modes?.[normalizedContentType];
|
||||
if (fromSource) {
|
||||
return capModeToCeiling(fromSource, defaultMode);
|
||||
}
|
||||
|
||||
const rules = Array.isArray(policy?.rules) ? policy.rules : [];
|
||||
const precedence: Array<[string, ContentType | '*']> = [
|
||||
[normalizedSource, normalizedContentType],
|
||||
[normalizedSource, '*'],
|
||||
['*', normalizedContentType],
|
||||
['*', '*'],
|
||||
];
|
||||
|
||||
for (const [sourceMatch, contentTypeMatch] of precedence) {
|
||||
const matchedRule = rules.find((rule) => {
|
||||
if (!rule || typeof rule !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const ruleSource = normalizeRuleSource((rule as Record<string, unknown>).source);
|
||||
const ruleContentType = normalizeRuleContentType((rule as Record<string, unknown>).content_type);
|
||||
return ruleSource === sourceMatch && ruleContentType === contentTypeMatch;
|
||||
});
|
||||
|
||||
if (!matchedRule || typeof matchedRule !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsedMode = parseMatrixMode((matchedRule as Record<string, unknown>).mode);
|
||||
if (!parsedMode) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return capModeToCeiling(parsedMode, defaultMode);
|
||||
}
|
||||
|
||||
return defaultMode;
|
||||
};
|
||||
|
||||
export class RequestPolicyCache {
|
||||
private ttlMs: number;
|
||||
private policy: RequestPolicyResponse | null = null;
|
||||
private lastFetchedAt = 0;
|
||||
private inFlight: Promise<RequestPolicyResponse | null> | null = null;
|
||||
private inFlightWasForced = false;
|
||||
|
||||
constructor(
|
||||
private readonly fetchPolicy: () => Promise<RequestPolicyResponse>,
|
||||
ttlMs: number = DEFAULT_POLICY_TTL_MS
|
||||
) {
|
||||
this.ttlMs = ttlMs;
|
||||
}
|
||||
|
||||
setTtlMs(ttlMs: number): void {
|
||||
this.ttlMs = ttlMs;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.policy = null;
|
||||
this.lastFetchedAt = 0;
|
||||
this.inFlight = null;
|
||||
this.inFlightWasForced = false;
|
||||
}
|
||||
|
||||
async refresh({
|
||||
enabled,
|
||||
isAdmin,
|
||||
force = false,
|
||||
}: RefreshPolicyOptions): Promise<RequestPolicyResponse | null> {
|
||||
if (!enabled || isAdmin) {
|
||||
this.reset();
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (!force && this.policy && now - this.lastFetchedAt < this.ttlMs) {
|
||||
return this.policy;
|
||||
}
|
||||
|
||||
if (this.inFlight) {
|
||||
// If a forced refresh arrives while a best-effort refresh is in-flight,
|
||||
// wait for the current request and then fetch a fresh snapshot.
|
||||
if (force && !this.inFlightWasForced) {
|
||||
try {
|
||||
await this.inFlight;
|
||||
} catch {
|
||||
// Ignore failures from the superseded in-flight request.
|
||||
}
|
||||
} else {
|
||||
return this.inFlight;
|
||||
}
|
||||
}
|
||||
|
||||
if (!force && this.policy && Date.now() - this.lastFetchedAt < this.ttlMs) {
|
||||
return this.policy;
|
||||
}
|
||||
|
||||
const requestPromise = this.fetchPolicy()
|
||||
.then((response) => {
|
||||
this.policy = response;
|
||||
this.lastFetchedAt = Date.now();
|
||||
return response;
|
||||
})
|
||||
.finally(() => {
|
||||
this.inFlight = null;
|
||||
this.inFlightWasForced = false;
|
||||
});
|
||||
|
||||
this.inFlightWasForced = force;
|
||||
this.inFlight = requestPromise;
|
||||
return requestPromise;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { LoginCredentials } from '../types';
|
||||
import { login, logout, checkAuth } from '../services/api';
|
||||
import { useSocket } from '../contexts/SocketContext';
|
||||
|
||||
interface UseAuthOptions {
|
||||
onLogoutSuccess?: () => void;
|
||||
@@ -27,6 +28,7 @@ interface UseAuthReturn {
|
||||
export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
|
||||
const { onLogoutSuccess, showToast } = options;
|
||||
const navigate = useNavigate();
|
||||
const { socket } = useSocket();
|
||||
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
|
||||
const [authRequired, setAuthRequired] = useState<boolean>(true);
|
||||
@@ -49,6 +51,16 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
|
||||
setOidcButtonLabel(response.oidc_button_label || null);
|
||||
}, []);
|
||||
|
||||
const refreshSocketSession = useCallback(() => {
|
||||
if (!socket) {
|
||||
return;
|
||||
}
|
||||
// Flask-SocketIO reads session state from the socket handshake context.
|
||||
// Reconnect after auth state changes so socket events use the latest session.
|
||||
socket.disconnect();
|
||||
socket.connect();
|
||||
}, [socket]);
|
||||
|
||||
// Check authentication on mount
|
||||
useEffect(() => {
|
||||
const verifyAuth = async () => {
|
||||
@@ -66,6 +78,35 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
|
||||
verifyAuth();
|
||||
}, [applyAuthResponse]);
|
||||
|
||||
// Re-sync auth when returning to the tab, so role/session changes in
|
||||
// another tab/profile don't leave stale local auth state.
|
||||
useEffect(() => {
|
||||
const verifyAuthOnFocus = async () => {
|
||||
try {
|
||||
applyAuthResponse(await checkAuth());
|
||||
} catch (error) {
|
||||
console.error('Auth re-check failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
void verifyAuthOnFocus();
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void verifyAuthOnFocus();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleFocus);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
return () => {
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [applyAuthResponse]);
|
||||
|
||||
const handleLogin = useCallback(async (credentials: LoginCredentials) => {
|
||||
setIsLoggingIn(true);
|
||||
setLoginError(null);
|
||||
@@ -74,6 +115,7 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
|
||||
if (response.success) {
|
||||
// Re-check auth to get updated session state
|
||||
applyAuthResponse(await checkAuth());
|
||||
refreshSocketSession();
|
||||
setLoginError(null);
|
||||
navigate('/', { replace: true });
|
||||
} else {
|
||||
@@ -88,7 +130,7 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
|
||||
} finally {
|
||||
setIsLoggingIn(false);
|
||||
}
|
||||
}, [navigate, applyAuthResponse]);
|
||||
}, [navigate, applyAuthResponse, refreshSocketSession]);
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
try {
|
||||
@@ -97,14 +139,19 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
|
||||
window.location.href = logout_url;
|
||||
return;
|
||||
}
|
||||
refreshSocketSession();
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setDisplayName(null);
|
||||
setOidcButtonLabel(null);
|
||||
onLogoutSuccess?.();
|
||||
navigate('/login', { replace: true });
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
showToast?.('Logout failed', 'error');
|
||||
}
|
||||
}, [navigate, onLogoutSuccess, showToast]);
|
||||
}, [navigate, onLogoutSuccess, refreshSocketSession, showToast]);
|
||||
|
||||
return {
|
||||
isAuthenticated,
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { fetchRequestPolicy } from '../services/api';
|
||||
import { ContentType, RequestPolicyMode, RequestPolicyResponse } from '../types';
|
||||
import {
|
||||
DEFAULT_POLICY_TTL_MS,
|
||||
RequestPolicyCache,
|
||||
resolveDefaultModeFromPolicy,
|
||||
resolveSourceModeFromPolicy,
|
||||
} from './requestPolicyCore';
|
||||
import { policyTrace } from '../utils/policyTrace';
|
||||
|
||||
interface UseRequestPolicyOptions {
|
||||
enabled: boolean;
|
||||
isAdmin: boolean;
|
||||
ttlMs?: number;
|
||||
}
|
||||
|
||||
interface UseRequestPolicyReturn {
|
||||
policy: RequestPolicyResponse | null;
|
||||
isLoading: boolean;
|
||||
isAdmin: boolean;
|
||||
requestsEnabled: boolean;
|
||||
allowNotes: boolean;
|
||||
getDefaultMode: (contentType: ContentType | string) => RequestPolicyMode;
|
||||
getSourceMode: (source: string, contentType: ContentType | string) => RequestPolicyMode;
|
||||
refresh: (options?: { force?: boolean }) => Promise<RequestPolicyResponse | null>;
|
||||
}
|
||||
|
||||
export function useRequestPolicy({
|
||||
enabled,
|
||||
isAdmin,
|
||||
ttlMs = DEFAULT_POLICY_TTL_MS,
|
||||
}: UseRequestPolicyOptions): UseRequestPolicyReturn {
|
||||
const [policy, setPolicy] = useState<RequestPolicyResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const cacheRef = useRef<RequestPolicyCache | null>(null);
|
||||
|
||||
if (!cacheRef.current) {
|
||||
cacheRef.current = new RequestPolicyCache(fetchRequestPolicy, ttlMs);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
cacheRef.current?.setTtlMs(ttlMs);
|
||||
}, [ttlMs]);
|
||||
|
||||
const fetchPolicy = useCallback(
|
||||
async (force: boolean): Promise<RequestPolicyResponse | null> => {
|
||||
const cache = cacheRef.current;
|
||||
if (!cache) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
cache.reset();
|
||||
setPolicy(null);
|
||||
setIsLoading(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
policyTrace('policy.refresh:start', { force, enabled, isAdmin });
|
||||
// Always fetch server policy while authenticated so backend auth state
|
||||
// remains authoritative even if local auth state is stale.
|
||||
const response = await cache.refresh({ enabled, isAdmin: false, force });
|
||||
policyTrace('policy.refresh:ok', {
|
||||
force,
|
||||
requestsEnabled: response?.requests_enabled ?? null,
|
||||
defaults: response?.defaults ?? null,
|
||||
});
|
||||
setPolicy(response);
|
||||
return response;
|
||||
} catch (error) {
|
||||
policyTrace('policy.refresh:error', {
|
||||
force,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[enabled, isAdmin]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
cacheRef.current?.reset();
|
||||
setPolicy(null);
|
||||
return;
|
||||
}
|
||||
void fetchPolicy(true);
|
||||
}, [enabled, fetchPolicy]);
|
||||
|
||||
const getDefaultMode = useCallback(
|
||||
(contentType: ContentType | string): RequestPolicyMode => {
|
||||
const effectiveIsAdmin = policy ? Boolean(policy.is_admin) : isAdmin;
|
||||
return resolveDefaultModeFromPolicy(policy, effectiveIsAdmin, contentType);
|
||||
},
|
||||
[policy, isAdmin]
|
||||
);
|
||||
|
||||
const getSourceMode = useCallback(
|
||||
(source: string, contentType: ContentType | string): RequestPolicyMode => {
|
||||
const effectiveIsAdmin = policy ? Boolean(policy.is_admin) : isAdmin;
|
||||
return resolveSourceModeFromPolicy(policy, effectiveIsAdmin, source, contentType);
|
||||
},
|
||||
[policy, isAdmin]
|
||||
);
|
||||
|
||||
const refresh = useCallback(async (options: { force?: boolean } = {}) => {
|
||||
return fetchPolicy(Boolean(options.force));
|
||||
}, [fetchPolicy]);
|
||||
|
||||
return {
|
||||
policy,
|
||||
isLoading,
|
||||
isAdmin: policy ? Boolean(policy.is_admin) : isAdmin,
|
||||
requestsEnabled: Boolean(policy?.requests_enabled),
|
||||
allowNotes: policy?.allow_notes ?? true,
|
||||
getDefaultMode,
|
||||
getSourceMode,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { RequestRecord } from '../types';
|
||||
|
||||
type RequestUpdateStatus = RequestRecord['status'];
|
||||
|
||||
export interface RequestUpdateEventPayload {
|
||||
request_id: number;
|
||||
status: RequestUpdateStatus;
|
||||
}
|
||||
|
||||
const isValidRequestStatus = (value: unknown): value is RequestUpdateStatus => {
|
||||
return value === 'pending' || value === 'fulfilled' || value === 'rejected' || value === 'cancelled';
|
||||
};
|
||||
|
||||
export const normalizeRequestUpdatePayload = (payload: unknown): RequestUpdateEventPayload | null => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = payload as Record<string, unknown>;
|
||||
const requestId = row.request_id;
|
||||
const status = row.status;
|
||||
|
||||
if (typeof requestId !== 'number' || !Number.isFinite(requestId) || !isValidRequestStatus(status)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
request_id: requestId,
|
||||
status,
|
||||
};
|
||||
};
|
||||
|
||||
export const upsertRequestRecord = (
|
||||
records: RequestRecord[],
|
||||
updated: RequestRecord
|
||||
): RequestRecord[] => {
|
||||
const index = records.findIndex((record) => record.id === updated.id);
|
||||
if (index === -1) {
|
||||
return [updated, ...records].sort(
|
||||
(left, right) => Date.parse(right.created_at) - Date.parse(left.created_at)
|
||||
);
|
||||
}
|
||||
|
||||
const next = [...records];
|
||||
next[index] = updated;
|
||||
return next;
|
||||
};
|
||||
|
||||
export const applyRequestUpdateEvent = (
|
||||
records: RequestRecord[],
|
||||
payload: RequestUpdateEventPayload
|
||||
): { records: RequestRecord[]; found: boolean } => {
|
||||
let found = false;
|
||||
const next = records.map((record) => {
|
||||
if (record.id !== payload.request_id) {
|
||||
return record;
|
||||
}
|
||||
found = true;
|
||||
return {
|
||||
...record,
|
||||
status: payload.status,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
return { records: next, found };
|
||||
};
|
||||
@@ -0,0 +1,294 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
cancelRequest as cancelUserRequest,
|
||||
fulfilAdminRequest,
|
||||
isApiResponseError,
|
||||
listAdminRequests,
|
||||
listRequests,
|
||||
rejectAdminRequest,
|
||||
} from '../services/api';
|
||||
import { RequestRecord } from '../types';
|
||||
import { useSocket } from '../contexts/SocketContext';
|
||||
import {
|
||||
applyRequestUpdateEvent,
|
||||
normalizeRequestUpdatePayload,
|
||||
upsertRequestRecord,
|
||||
} from './useRequests.helpers';
|
||||
import type { RequestUpdateEventPayload } from './useRequests.helpers';
|
||||
|
||||
interface UseRequestsOptions {
|
||||
isAdmin: boolean;
|
||||
enabled: boolean;
|
||||
pollIntervalMs?: number;
|
||||
}
|
||||
|
||||
export interface UseRequestsReturn {
|
||||
requests: RequestRecord[];
|
||||
pendingCount: number;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
cancelRequest: (id: number) => Promise<void>;
|
||||
fulfilRequest: (
|
||||
id: number,
|
||||
releaseData?: Record<string, unknown>,
|
||||
adminNote?: string
|
||||
) => Promise<void>;
|
||||
rejectRequest: (id: number, adminNote?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const toErrorMessage = (error: unknown, fallback: string): string => {
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const shouldFallbackToUserRequestList = (error: unknown): boolean => {
|
||||
return isApiResponseError(error) && (error.status === 401 || error.status === 403);
|
||||
};
|
||||
|
||||
export const useRequests = ({
|
||||
isAdmin,
|
||||
enabled,
|
||||
pollIntervalMs = 10_000,
|
||||
}: UseRequestsOptions): UseRequestsReturn => {
|
||||
const { socket, connected } = useSocket();
|
||||
const [requests, setRequests] = useState<RequestRecord[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const requestsRef = useRef<RequestRecord[]>([]);
|
||||
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
requestsRef.current = requests;
|
||||
}, [requests]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
let rows: RequestRecord[];
|
||||
if (isAdmin) {
|
||||
try {
|
||||
rows = await listAdminRequests();
|
||||
} catch (err) {
|
||||
// Role/session state can momentarily desync between tabs.
|
||||
// If admin list is unauthorized, fall back to user-scoped list.
|
||||
if (shouldFallbackToUserRequestList(err)) {
|
||||
rows = await listRequests();
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
rows = await listRequests();
|
||||
}
|
||||
setRequests(rows);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(toErrorMessage(err, 'Failed to load requests'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [enabled, isAdmin]);
|
||||
|
||||
const startPolling = useCallback(() => {
|
||||
if (pollIntervalRef.current || !enabled) {
|
||||
return;
|
||||
}
|
||||
pollIntervalRef.current = setInterval(() => {
|
||||
void refresh();
|
||||
}, pollIntervalMs);
|
||||
}, [enabled, refresh, pollIntervalMs]);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setRequests([]);
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
void refresh();
|
||||
}, [enabled, refresh, stopPolling]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!socket) {
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
const handleNewRequest = () => {
|
||||
void refresh();
|
||||
};
|
||||
|
||||
const handleRequestUpdate = (rawPayload: unknown) => {
|
||||
const payload = normalizeRequestUpdatePayload(rawPayload);
|
||||
if (!payload) {
|
||||
void refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
let found = false;
|
||||
setRequests((prev) => {
|
||||
const result = applyRequestUpdateEvent(prev, payload);
|
||||
found = result.found;
|
||||
return result.records;
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
void refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure we pick up full record updates (e.g. admin_note) after status transitions.
|
||||
void refresh();
|
||||
};
|
||||
|
||||
socket.on('new_request', handleNewRequest);
|
||||
socket.on('request_update', handleRequestUpdate);
|
||||
|
||||
if (connected) {
|
||||
stopPolling();
|
||||
} else {
|
||||
startPolling();
|
||||
}
|
||||
|
||||
return () => {
|
||||
socket.off('new_request', handleNewRequest);
|
||||
socket.off('request_update', handleRequestUpdate);
|
||||
};
|
||||
}, [enabled, socket, connected, refresh, startPolling, stopPolling]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
if (connected) {
|
||||
stopPolling();
|
||||
} else {
|
||||
startPolling();
|
||||
}
|
||||
}, [enabled, connected, startPolling, stopPolling]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopPolling();
|
||||
};
|
||||
}, [stopPolling]);
|
||||
|
||||
const cancelRequest = useCallback(async (id: number) => {
|
||||
const previous = requestsRef.current;
|
||||
setRequests((prev) => {
|
||||
const result = applyRequestUpdateEvent(prev, { request_id: id, status: 'cancelled' });
|
||||
return result.records;
|
||||
});
|
||||
|
||||
try {
|
||||
const updated = await cancelUserRequest(id);
|
||||
setRequests((prev) => upsertRequestRecord(prev, updated));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setRequests(previous);
|
||||
const message = toErrorMessage(err, 'Failed to cancel request');
|
||||
setError(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fulfilRequest = useCallback(
|
||||
async (id: number, releaseData?: Record<string, unknown>, adminNote?: string) => {
|
||||
if (!isAdmin) {
|
||||
throw new Error('Admin access required');
|
||||
}
|
||||
|
||||
const previous = requestsRef.current;
|
||||
setRequests((prev) => {
|
||||
const result = applyRequestUpdateEvent(prev, { request_id: id, status: 'fulfilled' });
|
||||
return result.records;
|
||||
});
|
||||
|
||||
try {
|
||||
const updated = await fulfilAdminRequest(id, {
|
||||
release_data: releaseData,
|
||||
admin_note: adminNote,
|
||||
});
|
||||
setRequests((prev) => upsertRequestRecord(prev, updated));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setRequests(previous);
|
||||
const message = toErrorMessage(err, 'Failed to fulfil request');
|
||||
setError(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
[isAdmin]
|
||||
);
|
||||
|
||||
const rejectRequest = useCallback(
|
||||
async (id: number, adminNote?: string) => {
|
||||
if (!isAdmin) {
|
||||
throw new Error('Admin access required');
|
||||
}
|
||||
|
||||
const previous = requestsRef.current;
|
||||
setRequests((prev) => {
|
||||
const result = applyRequestUpdateEvent(prev, { request_id: id, status: 'rejected' });
|
||||
return result.records;
|
||||
});
|
||||
|
||||
try {
|
||||
const updated = await rejectAdminRequest(id, {
|
||||
admin_note: adminNote,
|
||||
});
|
||||
setRequests((prev) => upsertRequestRecord(prev, updated));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setRequests(previous);
|
||||
const message = toErrorMessage(err, 'Failed to reject request');
|
||||
setError(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
[isAdmin]
|
||||
);
|
||||
|
||||
const pendingCount = useMemo(
|
||||
() => requests.filter((record) => record.status === 'pending').length,
|
||||
[requests]
|
||||
);
|
||||
|
||||
return {
|
||||
requests,
|
||||
pendingCount,
|
||||
isLoading,
|
||||
error,
|
||||
refresh,
|
||||
cancelRequest,
|
||||
fulfilRequest,
|
||||
rejectRequest,
|
||||
};
|
||||
};
|
||||
|
||||
export type { RequestUpdateEventPayload };
|
||||
export { upsertRequestRecord, applyRequestUpdateEvent };
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
UpdateResult,
|
||||
} from '../types/settings';
|
||||
|
||||
type ValueBearingField = Exclude<
|
||||
SettingsField,
|
||||
{ type: 'ActionButton' } | { type: 'HeadingField' } | { type: 'CustomComponentField' }
|
||||
>;
|
||||
|
||||
// Client-side only theme field that gets injected into the general tab
|
||||
const THEME_FIELD: SelectFieldConfig = {
|
||||
type: 'SelectField',
|
||||
@@ -34,7 +39,11 @@ function applyTheme(theme: string): void {
|
||||
// Extract value from a field based on its type
|
||||
function getFieldValue(field: SettingsField): unknown {
|
||||
// These field types have no value property
|
||||
if (field.type === 'ActionButton' || field.type === 'HeadingField') {
|
||||
if (
|
||||
field.type === 'ActionButton'
|
||||
|| field.type === 'HeadingField'
|
||||
|| field.type === 'CustomComponentField'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -46,6 +55,35 @@ function getFieldValue(field: SettingsField): unknown {
|
||||
return field.value ?? '';
|
||||
}
|
||||
|
||||
function getValueBearingFields(fields: SettingsField[]): ValueBearingField[] {
|
||||
const seen = new Set<string>();
|
||||
const valueFields: ValueBearingField[] = [];
|
||||
|
||||
const collect = (items: SettingsField[]) => {
|
||||
items.forEach((field) => {
|
||||
if (field.type === 'CustomComponentField') {
|
||||
if (field.boundFields && field.boundFields.length > 0) {
|
||||
collect(field.boundFields);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (field.type === 'ActionButton' || field.type === 'HeadingField') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (seen.has(field.key)) {
|
||||
return;
|
||||
}
|
||||
seen.add(field.key);
|
||||
valueFields.push(field);
|
||||
});
|
||||
};
|
||||
|
||||
collect(fields);
|
||||
return valueFields;
|
||||
}
|
||||
|
||||
interface UseSettingsReturn {
|
||||
tabs: SettingsTab[];
|
||||
groups: SettingsGroup[];
|
||||
@@ -97,14 +135,12 @@ export function useSettings(): UseSettingsReturn {
|
||||
const initialValues: Record<string, Record<string, unknown>> = {};
|
||||
tabsWithTheme.forEach((tab) => {
|
||||
initialValues[tab.name] = {};
|
||||
tab.fields.forEach((field) => {
|
||||
if (field.type !== 'ActionButton') {
|
||||
// Special handling for theme field - get from localStorage
|
||||
if (field.key === '_THEME') {
|
||||
initialValues[tab.name][field.key] = localStorage.getItem('preferred-theme') || 'auto';
|
||||
} else {
|
||||
initialValues[tab.name][field.key] = getFieldValue(field);
|
||||
}
|
||||
getValueBearingFields(tab.fields).forEach((field) => {
|
||||
// Special handling for theme field - get from localStorage
|
||||
if (field.key === '_THEME') {
|
||||
initialValues[tab.name][field.key] = localStorage.getItem('preferred-theme') || 'auto';
|
||||
} else {
|
||||
initialValues[tab.name][field.key] = getFieldValue(field);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -163,9 +199,7 @@ export function useSettings(): UseSettingsReturn {
|
||||
const tab = tabs.find((t) => t.name === tabName);
|
||||
if (!tab) return false;
|
||||
|
||||
for (const field of tab.fields) {
|
||||
if (field.type === 'ActionButton' || field.type === 'HeadingField') continue;
|
||||
|
||||
for (const field of getValueBearingFields(tab.fields)) {
|
||||
const currentValue = current[field.key];
|
||||
const originalValue = original[field.key];
|
||||
|
||||
@@ -192,8 +226,7 @@ export function useSettings(): UseSettingsReturn {
|
||||
const valuesToSave: Record<string, unknown> = {};
|
||||
|
||||
if (tab) {
|
||||
for (const field of tab.fields) {
|
||||
if (field.type === 'ActionButton' || field.type === 'HeadingField') continue;
|
||||
for (const field of getValueBearingFields(tab.fields)) {
|
||||
if (field.fromEnv) continue; // Skip env-locked fields
|
||||
if (field.key === '_THEME') continue; // Skip client-side only theme field
|
||||
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
import { Book, StatusData, AppConfig, LoginCredentials, AuthResponse, ReleaseSource, ReleasesResponse } from '../types';
|
||||
import {
|
||||
Book,
|
||||
StatusData,
|
||||
AppConfig,
|
||||
LoginCredentials,
|
||||
AuthResponse,
|
||||
ReleaseSource,
|
||||
ReleasesResponse,
|
||||
RequestPolicyResponse,
|
||||
CreateRequestPayload,
|
||||
RequestRecord,
|
||||
} from '../types';
|
||||
import { SettingsResponse, ActionResult, UpdateResult, SettingsTab } from '../types/settings';
|
||||
import { MetadataBookData, transformMetadataToBook } from '../utils/bookTransformers';
|
||||
import { getApiBase } from '../utils/basePath';
|
||||
import {
|
||||
buildAdminRequestActionUrl,
|
||||
buildFulfilAdminRequestBody,
|
||||
buildRejectAdminRequestBody,
|
||||
buildRequestListUrl,
|
||||
FulfilAdminRequestBody,
|
||||
RejectAdminRequestBody,
|
||||
RequestListParams,
|
||||
} from './requestApiHelpers';
|
||||
|
||||
const API_BASE = getApiBase();
|
||||
|
||||
@@ -20,6 +40,10 @@ const API = {
|
||||
logout: `${API_BASE}/auth/logout`,
|
||||
authCheck: `${API_BASE}/auth/check`,
|
||||
settings: `${API_BASE}/settings`,
|
||||
requestPolicy: `${API_BASE}/request-policy`,
|
||||
requests: `${API_BASE}/requests`,
|
||||
adminRequests: `${API_BASE}/admin/requests`,
|
||||
adminRequestCounts: `${API_BASE}/admin/requests/count`,
|
||||
};
|
||||
|
||||
// Custom error class for authentication failures
|
||||
@@ -38,6 +62,34 @@ export class TimeoutError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiResponseError extends Error {
|
||||
status: number;
|
||||
code?: string;
|
||||
requiredMode?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
params: {
|
||||
status: number;
|
||||
code?: string;
|
||||
requiredMode?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiResponseError';
|
||||
this.status = params.status;
|
||||
this.code = params.code;
|
||||
this.requiredMode = params.requiredMode;
|
||||
this.payload = params.payload;
|
||||
}
|
||||
}
|
||||
|
||||
export const isApiResponseError = (error: unknown): error is ApiResponseError => {
|
||||
return error instanceof ApiResponseError;
|
||||
};
|
||||
|
||||
// Default request timeout in milliseconds (30 seconds)
|
||||
const DEFAULT_TIMEOUT_MS = 30000;
|
||||
|
||||
@@ -67,13 +119,17 @@ async function fetchJSON<T>(
|
||||
// Try to parse error message from response body
|
||||
let errorMessage = `${res.status} ${res.statusText}`;
|
||||
let hasServerMessage = false;
|
||||
let errorData: Record<string, unknown> | null = null;
|
||||
try {
|
||||
const errorData = await res.json();
|
||||
const parsed = await res.json();
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
errorData = parsed as Record<string, unknown>;
|
||||
}
|
||||
// Prefer user-friendly 'message' field, fall back to 'error'
|
||||
if (errorData.message) {
|
||||
if (typeof errorData?.message === 'string') {
|
||||
errorMessage = errorData.message;
|
||||
hasServerMessage = true;
|
||||
} else if (errorData.error) {
|
||||
} else if (typeof errorData?.error === 'string') {
|
||||
errorMessage = errorData.error;
|
||||
hasServerMessage = true;
|
||||
}
|
||||
@@ -94,7 +150,13 @@ async function fetchJSON<T>(
|
||||
throw new AuthenticationError(errorMessage);
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
throw new ApiResponseError(errorMessage, {
|
||||
status: res.status,
|
||||
code: typeof errorData?.code === 'string' ? errorData.code : undefined,
|
||||
requiredMode:
|
||||
typeof errorData?.required_mode === 'string' ? errorData.required_mode : undefined,
|
||||
payload: errorData || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json();
|
||||
@@ -239,6 +301,65 @@ export const getConfig = async (): Promise<AppConfig> => {
|
||||
return fetchJSON<AppConfig>(API.config);
|
||||
};
|
||||
|
||||
export type ListRequestsParams = RequestListParams;
|
||||
|
||||
export interface AdminRequestCounts {
|
||||
pending: number;
|
||||
total: number;
|
||||
by_status: Record<string, number>;
|
||||
}
|
||||
|
||||
export const fetchRequestPolicy = async (): Promise<RequestPolicyResponse> => {
|
||||
return fetchJSON<RequestPolicyResponse>(API.requestPolicy);
|
||||
};
|
||||
|
||||
export const createRequest = async (payload: CreateRequestPayload): Promise<RequestRecord> => {
|
||||
return fetchJSON<RequestRecord>(API.requests, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
};
|
||||
|
||||
export const listRequests = async (params: ListRequestsParams = {}): Promise<RequestRecord[]> => {
|
||||
const url = buildRequestListUrl(API.requests, params);
|
||||
return fetchJSON<RequestRecord[]>(url);
|
||||
};
|
||||
|
||||
export const cancelRequest = async (id: number): Promise<RequestRecord> => {
|
||||
return fetchJSON<RequestRecord>(`${API.requests}/${encodeURIComponent(String(id))}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
};
|
||||
|
||||
export const listAdminRequests = async (params: ListRequestsParams = {}): Promise<RequestRecord[]> => {
|
||||
const url = buildRequestListUrl(API.adminRequests, params);
|
||||
return fetchJSON<RequestRecord[]>(url);
|
||||
};
|
||||
|
||||
export const getAdminRequestCounts = async (): Promise<AdminRequestCounts> => {
|
||||
return fetchJSON<AdminRequestCounts>(API.adminRequestCounts);
|
||||
};
|
||||
|
||||
export const fulfilAdminRequest = async (
|
||||
id: number,
|
||||
body: FulfilAdminRequestBody = {}
|
||||
): Promise<RequestRecord> => {
|
||||
return fetchJSON<RequestRecord>(buildAdminRequestActionUrl(API.adminRequests, id, 'fulfil'), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(buildFulfilAdminRequestBody(body)),
|
||||
});
|
||||
};
|
||||
|
||||
export const rejectAdminRequest = async (
|
||||
id: number,
|
||||
body: RejectAdminRequestBody = {}
|
||||
): Promise<RequestRecord> => {
|
||||
return fetchJSON<RequestRecord>(buildAdminRequestActionUrl(API.adminRequests, id, 'reject'), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(buildRejectAdminRequestBody(body)),
|
||||
});
|
||||
};
|
||||
|
||||
// Authentication functions
|
||||
export const login = async (credentials: LoginCredentials): Promise<AuthResponse> => {
|
||||
return fetchJSON<AuthResponse>(API.login, {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { RequestRecord } from '../types';
|
||||
|
||||
export interface RequestListParams {
|
||||
status?: RequestRecord['status'];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface FulfilAdminRequestBody {
|
||||
release_data?: Record<string, unknown>;
|
||||
admin_note?: string;
|
||||
}
|
||||
|
||||
export interface RejectAdminRequestBody {
|
||||
admin_note?: string;
|
||||
}
|
||||
|
||||
export const buildRequestListUrl = (
|
||||
baseUrl: string,
|
||||
params: RequestListParams = {}
|
||||
): string => {
|
||||
const query = new URLSearchParams();
|
||||
if (params.status) {
|
||||
query.set('status', params.status);
|
||||
}
|
||||
if (typeof params.limit === 'number') {
|
||||
query.set('limit', String(params.limit));
|
||||
}
|
||||
if (typeof params.offset === 'number') {
|
||||
query.set('offset', String(params.offset));
|
||||
}
|
||||
|
||||
const queryString = query.toString();
|
||||
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
|
||||
};
|
||||
|
||||
export const buildAdminRequestActionUrl = (
|
||||
adminRequestsBaseUrl: string,
|
||||
id: number,
|
||||
action: 'fulfil' | 'reject'
|
||||
): string => {
|
||||
return `${adminRequestsBaseUrl}/${encodeURIComponent(String(id))}/${action}`;
|
||||
};
|
||||
|
||||
export const buildFulfilAdminRequestBody = (
|
||||
body: FulfilAdminRequestBody = {}
|
||||
): FulfilAdminRequestBody => {
|
||||
return {
|
||||
release_data: body.release_data,
|
||||
admin_note: body.admin_note,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildRejectAdminRequestBody = (
|
||||
body: RejectAdminRequestBody = {}
|
||||
): RejectAdminRequestBody => {
|
||||
return {
|
||||
admin_note: body.admin_note,
|
||||
};
|
||||
};
|
||||
@@ -184,6 +184,15 @@ footer {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes wave {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.activity-wave {
|
||||
animation: wave 2s linear infinite;
|
||||
}
|
||||
|
||||
/* Use translate3d() for GPU acceleration on iOS Safari */
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { getActivityBadgeState } from '../utils/activityBadge.js';
|
||||
|
||||
describe('activityBadge.getActivityBadgeState', () => {
|
||||
it('returns null when there is no activity', () => {
|
||||
const badge = getActivityBadgeState(
|
||||
{ ongoing: 0, completed: 0, errored: 0, pendingRequests: 0 },
|
||||
true
|
||||
);
|
||||
assert.equal(badge, null);
|
||||
});
|
||||
|
||||
it('prioritizes red when errors are present', () => {
|
||||
const badge = getActivityBadgeState(
|
||||
{ ongoing: 1, completed: 2, errored: 1, pendingRequests: 5 },
|
||||
true
|
||||
);
|
||||
assert.ok(badge);
|
||||
assert.equal(badge?.colorClass, 'bg-red-500');
|
||||
assert.equal(badge?.total, 9);
|
||||
});
|
||||
|
||||
it('uses amber for admin pending requests when downloads are idle', () => {
|
||||
const badge = getActivityBadgeState(
|
||||
{ ongoing: 0, completed: 0, errored: 0, pendingRequests: 3 },
|
||||
true
|
||||
);
|
||||
assert.ok(badge);
|
||||
assert.equal(badge?.colorClass, 'bg-amber-500');
|
||||
assert.equal(badge?.total, 3);
|
||||
});
|
||||
|
||||
it('ignores pending requests for non-admin badge totals', () => {
|
||||
const badge = getActivityBadgeState(
|
||||
{ ongoing: 0, completed: 1, errored: 0, pendingRequests: 4 },
|
||||
false
|
||||
);
|
||||
assert.ok(badge);
|
||||
assert.equal(badge?.colorClass, 'bg-green-500');
|
||||
assert.equal(badge?.total, 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import * as assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import type { ActivityItem } from '../components/activity/activityTypes.js';
|
||||
import { buildActivityCardModel } from '../components/activity/activityCardModel.js';
|
||||
|
||||
const makeItem = (overrides: Partial<ActivityItem> = {}): ActivityItem => ({
|
||||
id: 'book-1',
|
||||
kind: 'download',
|
||||
visualStatus: 'complete',
|
||||
title: 'The Martian',
|
||||
author: 'Andy Weir',
|
||||
metaLine: 'EPUB | 1.0MB | Direct Download',
|
||||
statusLabel: 'Complete',
|
||||
timestamp: 1,
|
||||
downloadBookId: 'book-1',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('activityCardModel', () => {
|
||||
it('shows ownership in badge text for admin pending requests', () => {
|
||||
const model = buildActivityCardModel(
|
||||
makeItem({
|
||||
kind: 'request',
|
||||
visualStatus: 'pending',
|
||||
statusLabel: 'Pending',
|
||||
requestId: 42,
|
||||
username: 'testuser',
|
||||
}),
|
||||
true
|
||||
);
|
||||
|
||||
assert.equal(model.badges.length, 1);
|
||||
assert.equal(model.badges[0]?.text, 'Requested by testuser');
|
||||
});
|
||||
|
||||
it('keeps pending label for requester-side pending requests', () => {
|
||||
const model = buildActivityCardModel(
|
||||
makeItem({
|
||||
kind: 'request',
|
||||
visualStatus: 'pending',
|
||||
statusLabel: 'Pending',
|
||||
requestId: 42,
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
assert.equal(model.badges.length, 1);
|
||||
assert.equal(model.badges[0]?.text, 'Pending');
|
||||
});
|
||||
|
||||
it('uses requester-friendly approved wording for fulfilled requests', () => {
|
||||
const model = buildActivityCardModel(
|
||||
makeItem({
|
||||
kind: 'request',
|
||||
visualStatus: 'fulfilled',
|
||||
statusLabel: 'Fulfilled',
|
||||
requestId: 42,
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
assert.equal(model.badges.length, 1);
|
||||
assert.equal(model.badges[0]?.text, 'Approved');
|
||||
});
|
||||
|
||||
it('shows approved in-progress request badge while linked download is active', () => {
|
||||
const model = buildActivityCardModel(
|
||||
makeItem({
|
||||
kind: 'download',
|
||||
visualStatus: 'downloading',
|
||||
statusLabel: 'Downloading',
|
||||
requestId: 42,
|
||||
requestRecord: {
|
||||
id: 42,
|
||||
user_id: 7,
|
||||
status: 'fulfilled',
|
||||
source_hint: 'prowlarr',
|
||||
content_type: 'ebook',
|
||||
request_level: 'release',
|
||||
policy_mode: 'request_release',
|
||||
book_data: { title: 'The Martian', author: 'Andy Weir' },
|
||||
release_data: { source_id: 'book-1' },
|
||||
note: null,
|
||||
admin_note: null,
|
||||
reviewed_by: null,
|
||||
reviewed_at: null,
|
||||
created_at: '2026-02-13T12:00:00Z',
|
||||
updated_at: '2026-02-13T12:00:00Z',
|
||||
username: 'testuser',
|
||||
},
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
assert.equal(model.badges.length, 2);
|
||||
assert.equal(model.badges[0]?.key, 'request');
|
||||
assert.equal(model.badges[0]?.text, 'Approved');
|
||||
assert.equal(model.badges[0]?.visualStatus, 'resolving');
|
||||
});
|
||||
|
||||
it('shows request and download badges side-by-side for merged request downloads', () => {
|
||||
const model = buildActivityCardModel(
|
||||
makeItem({
|
||||
visualStatus: 'complete',
|
||||
statusLabel: 'Complete',
|
||||
statusDetail: 'Sent to Kindle',
|
||||
requestId: 42,
|
||||
requestRecord: {
|
||||
id: 42,
|
||||
user_id: 7,
|
||||
status: 'fulfilled',
|
||||
source_hint: 'prowlarr',
|
||||
content_type: 'ebook',
|
||||
request_level: 'release',
|
||||
policy_mode: 'request_release',
|
||||
book_data: { title: 'The Martian', author: 'Andy Weir' },
|
||||
release_data: { source_id: 'book-1' },
|
||||
note: null,
|
||||
admin_note: null,
|
||||
reviewed_by: null,
|
||||
reviewed_at: null,
|
||||
created_at: '2026-02-13T12:00:00Z',
|
||||
updated_at: '2026-02-13T12:00:00Z',
|
||||
username: 'testuser',
|
||||
},
|
||||
}),
|
||||
true
|
||||
);
|
||||
|
||||
assert.equal(model.badges.length, 2);
|
||||
assert.equal(model.badges[0]?.key, 'request');
|
||||
assert.equal(model.badges[0]?.text, 'Request fulfilled');
|
||||
assert.equal(model.badges[1]?.key, 'download');
|
||||
assert.equal(model.badges[1]?.text, 'Sent to Kindle');
|
||||
});
|
||||
|
||||
it('builds pending admin request actions from one normalized source', () => {
|
||||
const model = buildActivityCardModel(
|
||||
makeItem({
|
||||
kind: 'request',
|
||||
visualStatus: 'pending',
|
||||
requestId: 42,
|
||||
requestRecord: {
|
||||
id: 42,
|
||||
user_id: 7,
|
||||
status: 'pending',
|
||||
source_hint: 'prowlarr',
|
||||
content_type: 'ebook',
|
||||
request_level: 'release',
|
||||
policy_mode: 'request_release',
|
||||
book_data: { title: 'The Martian', author: 'Andy Weir' },
|
||||
release_data: { source_id: 'book-1' },
|
||||
note: null,
|
||||
admin_note: null,
|
||||
reviewed_by: null,
|
||||
reviewed_at: null,
|
||||
created_at: '2026-02-13T12:00:00Z',
|
||||
updated_at: '2026-02-13T12:00:00Z',
|
||||
username: 'testuser',
|
||||
},
|
||||
}),
|
||||
true
|
||||
);
|
||||
|
||||
assert.equal(model.actions.length, 2);
|
||||
assert.equal(model.actions[0]?.kind, 'request-approve');
|
||||
assert.equal(model.actions[1]?.kind, 'request-reject');
|
||||
});
|
||||
|
||||
it('attaches linked request id when dismissing merged download cards', () => {
|
||||
const model = buildActivityCardModel(
|
||||
makeItem({
|
||||
requestId: 42,
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
assert.equal(model.actions.length, 1);
|
||||
assert.equal(model.actions[0]?.kind, 'download-dismiss');
|
||||
assert.equal(
|
||||
model.actions[0]?.kind === 'download-dismiss' ? model.actions[0].linkedRequestId : undefined,
|
||||
42
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import * as assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import type { Book, RequestRecord } from '../types/index.js';
|
||||
import { downloadToActivityItem, requestToActivityItem } from '../components/activity/activityMappers.js';
|
||||
|
||||
const makeBook = (overrides: Partial<Book> = {}): Book => ({
|
||||
id: 'book-1',
|
||||
title: 'The Test Book',
|
||||
author: 'Test Author',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeRequest = (overrides: Partial<RequestRecord> = {}): RequestRecord => ({
|
||||
id: 42,
|
||||
user_id: 7,
|
||||
status: 'pending',
|
||||
source_hint: 'prowlarr',
|
||||
content_type: 'ebook',
|
||||
request_level: 'release',
|
||||
policy_mode: 'request_release',
|
||||
book_data: {
|
||||
title: 'Request Title',
|
||||
author: 'Request Author',
|
||||
preview: 'https://example.com/cover.jpg',
|
||||
},
|
||||
release_data: {
|
||||
source: 'prowlarr',
|
||||
format: 'epub',
|
||||
size: '2 MB',
|
||||
},
|
||||
note: 'please add this',
|
||||
admin_note: null,
|
||||
reviewed_by: null,
|
||||
reviewed_at: null,
|
||||
created_at: '2026-02-13T12:00:00Z',
|
||||
updated_at: '2026-02-13T12:00:00Z',
|
||||
username: 'alice',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('activityMappers.downloadToActivityItem', () => {
|
||||
it('maps every download status key to its visual status', () => {
|
||||
const statusExpectations: Array<{
|
||||
statusKey: 'queued' | 'resolving' | 'locating' | 'downloading' | 'complete' | 'error' | 'cancelled';
|
||||
expectedVisualStatus: string;
|
||||
}> = [
|
||||
{ statusKey: 'queued', expectedVisualStatus: 'queued' },
|
||||
{ statusKey: 'resolving', expectedVisualStatus: 'resolving' },
|
||||
{ statusKey: 'locating', expectedVisualStatus: 'locating' },
|
||||
{ statusKey: 'downloading', expectedVisualStatus: 'downloading' },
|
||||
{ statusKey: 'complete', expectedVisualStatus: 'complete' },
|
||||
{ statusKey: 'error', expectedVisualStatus: 'error' },
|
||||
{ statusKey: 'cancelled', expectedVisualStatus: 'cancelled' },
|
||||
];
|
||||
|
||||
statusExpectations.forEach(({ statusKey, expectedVisualStatus }) => {
|
||||
const item = downloadToActivityItem(makeBook(), statusKey);
|
||||
assert.equal(item.visualStatus, expectedVisualStatus);
|
||||
});
|
||||
});
|
||||
|
||||
it('maps download items with meta line and status fields', () => {
|
||||
const item = downloadToActivityItem(
|
||||
makeBook({
|
||||
format: 'epub',
|
||||
size: '3 MB',
|
||||
source_display_name: 'Direct Download',
|
||||
username: 'alice',
|
||||
added_time: 123,
|
||||
}),
|
||||
'queued'
|
||||
);
|
||||
|
||||
assert.equal(item.kind, 'download');
|
||||
assert.equal(item.visualStatus, 'queued');
|
||||
assert.equal(item.statusLabel, 'Queued');
|
||||
assert.equal(item.metaLine, 'EPUB | 3 MB | Direct Download | alice');
|
||||
assert.equal(item.progress, 5);
|
||||
assert.equal(item.progressAnimated, true);
|
||||
assert.equal(item.timestamp, 123);
|
||||
});
|
||||
|
||||
it('maps downloading progress using 20 + progress*0.8', () => {
|
||||
const item = downloadToActivityItem(makeBook({ progress: 60 }), 'downloading');
|
||||
assert.equal(item.visualStatus, 'downloading');
|
||||
assert.equal(item.progress, 68);
|
||||
});
|
||||
|
||||
it('falls back to normalized source name when source_display_name is missing', () => {
|
||||
const item = downloadToActivityItem(makeBook({ source: 'direct_download' }), 'complete');
|
||||
assert.equal(item.metaLine, 'Direct Download');
|
||||
});
|
||||
|
||||
it('omits empty meta parts cleanly', () => {
|
||||
const item = downloadToActivityItem(makeBook({ format: 'epub', size: undefined }), 'error');
|
||||
assert.equal(item.metaLine, 'EPUB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('activityMappers.requestToActivityItem', () => {
|
||||
it('maps request statuses to visual statuses', () => {
|
||||
const statuses: Array<{ input: RequestRecord['status']; expected: string }> = [
|
||||
{ input: 'pending', expected: 'pending' },
|
||||
{ input: 'fulfilled', expected: 'fulfilled' },
|
||||
{ input: 'rejected', expected: 'rejected' },
|
||||
{ input: 'cancelled', expected: 'cancelled' },
|
||||
];
|
||||
|
||||
statuses.forEach(({ input, expected }) => {
|
||||
const item = requestToActivityItem(makeRequest({ status: input }), 'user');
|
||||
assert.equal(item.visualStatus, expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('maps release-level admin request with release meta and username', () => {
|
||||
const item = requestToActivityItem(makeRequest(), 'admin');
|
||||
|
||||
assert.equal(item.kind, 'request');
|
||||
assert.equal(item.visualStatus, 'pending');
|
||||
assert.equal(item.metaLine, 'EPUB | 2 MB | Prowlarr | alice');
|
||||
assert.equal(item.requestId, 42);
|
||||
assert.equal(item.requestLevel, 'release');
|
||||
assert.equal(item.requestNote, 'please add this');
|
||||
assert.equal(item.statusLabel, 'Pending');
|
||||
assert.ok(item.timestamp > 0);
|
||||
});
|
||||
|
||||
it('maps book-level user request without username in meta line', () => {
|
||||
const item = requestToActivityItem(
|
||||
makeRequest({
|
||||
request_level: 'book',
|
||||
release_data: null,
|
||||
source_hint: '*',
|
||||
}),
|
||||
'user'
|
||||
);
|
||||
|
||||
assert.equal(item.metaLine, 'Book request');
|
||||
});
|
||||
|
||||
it('maps rejected requests with admin note', () => {
|
||||
const item = requestToActivityItem(
|
||||
makeRequest({
|
||||
status: 'rejected',
|
||||
admin_note: 'Not available',
|
||||
}),
|
||||
'user'
|
||||
);
|
||||
|
||||
assert.equal(item.visualStatus, 'rejected');
|
||||
assert.equal(item.adminNote, 'Not available');
|
||||
});
|
||||
|
||||
it('does not append username to meta line for user viewer role', () => {
|
||||
const item = requestToActivityItem(makeRequest(), 'user');
|
||||
assert.equal(item.metaLine, 'EPUB | 2 MB | Prowlarr');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import * as assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import {
|
||||
STATUS_ACCENT_CLASSES,
|
||||
STATUS_BADGE_STYLES,
|
||||
getProgressConfig,
|
||||
isActiveDownloadStatus,
|
||||
} from '../components/activity/activityStyles.js';
|
||||
|
||||
describe('activityStyles', () => {
|
||||
it('maps accent classes for request and download statuses', () => {
|
||||
assert.equal(STATUS_ACCENT_CLASSES.queued, 'border-l-amber-500');
|
||||
assert.equal(STATUS_ACCENT_CLASSES.pending, 'border-l-amber-500');
|
||||
assert.equal(STATUS_ACCENT_CLASSES.downloading, 'border-l-sky-500');
|
||||
assert.equal(STATUS_ACCENT_CLASSES.fulfilled, 'border-l-green-500');
|
||||
assert.equal(STATUS_ACCENT_CLASSES.rejected, 'border-l-red-500');
|
||||
});
|
||||
|
||||
it('exposes badge style entries for all key statuses', () => {
|
||||
assert.equal(STATUS_BADGE_STYLES.queued.bg, 'bg-amber-500/15');
|
||||
assert.equal(STATUS_BADGE_STYLES.downloading.text, 'text-sky-700 dark:text-sky-300');
|
||||
assert.equal(STATUS_BADGE_STYLES.rejected.bg, 'bg-red-500/15');
|
||||
});
|
||||
|
||||
it('returns progress config values matching existing sidebar behavior', () => {
|
||||
assert.deepEqual(getProgressConfig('queued'), {
|
||||
percent: 5,
|
||||
color: 'bg-amber-600',
|
||||
animated: true,
|
||||
});
|
||||
assert.deepEqual(getProgressConfig('resolving'), {
|
||||
percent: 15,
|
||||
color: 'bg-indigo-600',
|
||||
animated: true,
|
||||
});
|
||||
assert.deepEqual(getProgressConfig('locating'), {
|
||||
percent: 90,
|
||||
color: 'bg-teal-600',
|
||||
animated: true,
|
||||
});
|
||||
|
||||
const downloading = getProgressConfig('downloading', 60);
|
||||
assert.equal(downloading.percent, 68);
|
||||
assert.equal(downloading.color, 'bg-sky-600');
|
||||
assert.equal(downloading.animated, true);
|
||||
|
||||
assert.deepEqual(getProgressConfig('complete'), {
|
||||
percent: 100,
|
||||
color: 'bg-green-600',
|
||||
animated: false,
|
||||
});
|
||||
assert.deepEqual(getProgressConfig('error'), {
|
||||
percent: 100,
|
||||
color: 'bg-red-600',
|
||||
animated: false,
|
||||
});
|
||||
assert.deepEqual(getProgressConfig('cancelled'), {
|
||||
percent: 100,
|
||||
color: 'bg-gray-500',
|
||||
animated: false,
|
||||
});
|
||||
assert.deepEqual(getProgressConfig('pending'), {
|
||||
percent: 0,
|
||||
color: 'bg-amber-600',
|
||||
animated: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('detects active download statuses only', () => {
|
||||
assert.equal(isActiveDownloadStatus('queued'), true);
|
||||
assert.equal(isActiveDownloadStatus('resolving'), true);
|
||||
assert.equal(isActiveDownloadStatus('locating'), true);
|
||||
assert.equal(isActiveDownloadStatus('downloading'), true);
|
||||
assert.equal(isActiveDownloadStatus('complete'), false);
|
||||
assert.equal(isActiveDownloadStatus('pending'), false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import {
|
||||
buildAdminRequestActionUrl,
|
||||
buildFulfilAdminRequestBody,
|
||||
buildRejectAdminRequestBody,
|
||||
buildRequestListUrl,
|
||||
} from '../services/requestApiHelpers.js';
|
||||
|
||||
describe('admin request API client functions', () => {
|
||||
it('builds list URL query params correctly', () => {
|
||||
const url = buildRequestListUrl('/api/admin/requests', {
|
||||
status: 'pending',
|
||||
limit: 10,
|
||||
offset: 5,
|
||||
});
|
||||
assert.equal(url, '/api/admin/requests?status=pending&limit=10&offset=5');
|
||||
});
|
||||
|
||||
it('returns bare list URL when no params are provided', () => {
|
||||
const url = buildRequestListUrl('/api/admin/requests');
|
||||
assert.equal(url, '/api/admin/requests');
|
||||
});
|
||||
|
||||
it('builds fulfil endpoint URL and payload shape', () => {
|
||||
const url = buildAdminRequestActionUrl('/api/admin/requests', 42, 'fulfil');
|
||||
const body = buildFulfilAdminRequestBody({
|
||||
release_data: { source: 'prowlarr', source_id: 'rel-42' },
|
||||
admin_note: 'Approved',
|
||||
});
|
||||
|
||||
assert.equal(url, '/api/admin/requests/42/fulfil');
|
||||
assert.deepEqual(body, {
|
||||
release_data: { source: 'prowlarr', source_id: 'rel-42' },
|
||||
admin_note: 'Approved',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds reject endpoint URL and payload shape', () => {
|
||||
const url = buildAdminRequestActionUrl('/api/admin/requests', 51, 'reject');
|
||||
const body = buildRejectAdminRequestBody({
|
||||
admin_note: 'No suitable release found',
|
||||
});
|
||||
|
||||
assert.equal(url, '/api/admin/requests/51/reject');
|
||||
assert.deepEqual(body, {
|
||||
admin_note: 'No suitable release found',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import * as assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import type { CreateRequestPayload } from '../types/index.js';
|
||||
import {
|
||||
applyRequestNoteToPayload,
|
||||
buildRequestConfirmationPreview,
|
||||
MAX_REQUEST_NOTE_LENGTH,
|
||||
truncateRequestNote,
|
||||
} from '../utils/requestConfirmation.js';
|
||||
|
||||
const releasePayload: CreateRequestPayload = {
|
||||
book_data: {
|
||||
title: 'Example Title',
|
||||
author: 'Example Author',
|
||||
preview: 'https://example.com/cover.jpg',
|
||||
},
|
||||
release_data: {
|
||||
source: 'prowlarr',
|
||||
format: 'epub',
|
||||
size: '2 MB',
|
||||
},
|
||||
context: {
|
||||
source: 'prowlarr',
|
||||
content_type: 'ebook',
|
||||
request_level: 'release',
|
||||
},
|
||||
};
|
||||
|
||||
const bookPayload: CreateRequestPayload = {
|
||||
book_data: {
|
||||
title: 'Book Level',
|
||||
author: 'Book Author',
|
||||
},
|
||||
release_data: null,
|
||||
context: {
|
||||
source: '*',
|
||||
content_type: 'ebook',
|
||||
request_level: 'book',
|
||||
},
|
||||
};
|
||||
|
||||
describe('requestConfirmation utilities', () => {
|
||||
it('builds release preview line for release-level payloads', () => {
|
||||
const preview = buildRequestConfirmationPreview(releasePayload);
|
||||
|
||||
assert.equal(preview.title, 'Example Title');
|
||||
assert.equal(preview.author, 'Example Author');
|
||||
assert.equal(preview.preview, 'https://example.com/cover.jpg');
|
||||
assert.equal(preview.releaseLine, 'EPUB | 2 MB | Prowlarr');
|
||||
assert.equal(preview.year, '');
|
||||
assert.equal(preview.seriesLine, '');
|
||||
});
|
||||
|
||||
it('omits release line for book-level payloads', () => {
|
||||
const preview = buildRequestConfirmationPreview(bookPayload);
|
||||
|
||||
assert.equal(preview.title, 'Book Level');
|
||||
assert.equal(preview.author, 'Book Author');
|
||||
assert.equal(preview.releaseLine, '');
|
||||
});
|
||||
|
||||
it('includes year and series info when present', () => {
|
||||
const payload: CreateRequestPayload = {
|
||||
book_data: {
|
||||
title: 'Dune',
|
||||
author: 'Frank Herbert',
|
||||
year: '1965',
|
||||
series_name: 'Dune Chronicles',
|
||||
series_position: 1,
|
||||
series_count: 6,
|
||||
},
|
||||
release_data: null,
|
||||
context: {
|
||||
source: '*',
|
||||
content_type: 'ebook',
|
||||
request_level: 'book',
|
||||
},
|
||||
};
|
||||
const preview = buildRequestConfirmationPreview(payload);
|
||||
|
||||
assert.equal(preview.year, '1965');
|
||||
assert.equal(preview.seriesLine, '#1 of 6 in Dune Chronicles');
|
||||
});
|
||||
|
||||
it('shows series position without count when count is absent', () => {
|
||||
const payload: CreateRequestPayload = {
|
||||
book_data: {
|
||||
title: 'Dune',
|
||||
author: 'Frank Herbert',
|
||||
series_name: 'Dune Chronicles',
|
||||
series_position: 1,
|
||||
},
|
||||
release_data: null,
|
||||
context: {
|
||||
source: '*',
|
||||
content_type: 'ebook',
|
||||
request_level: 'book',
|
||||
},
|
||||
};
|
||||
const preview = buildRequestConfirmationPreview(payload);
|
||||
assert.equal(preview.seriesLine, '#1 in Dune Chronicles');
|
||||
});
|
||||
|
||||
it('shows series name without position when position is absent', () => {
|
||||
const payload: CreateRequestPayload = {
|
||||
book_data: {
|
||||
title: 'Test',
|
||||
author: 'Author',
|
||||
series_name: 'My Series',
|
||||
},
|
||||
release_data: null,
|
||||
context: {
|
||||
source: '*',
|
||||
content_type: 'ebook',
|
||||
request_level: 'book',
|
||||
},
|
||||
};
|
||||
const preview = buildRequestConfirmationPreview(payload);
|
||||
assert.equal(preview.seriesLine, 'My Series');
|
||||
});
|
||||
|
||||
it('applies trimmed note when notes are allowed', () => {
|
||||
const result = applyRequestNoteToPayload(releasePayload, ' please add this ', true);
|
||||
assert.equal(result.note, 'please add this');
|
||||
});
|
||||
|
||||
it('drops note when notes are disabled or blank', () => {
|
||||
const withDisabledNotes = applyRequestNoteToPayload(
|
||||
{ ...releasePayload, note: 'existing note' },
|
||||
'new note',
|
||||
false
|
||||
);
|
||||
const withBlankNote = applyRequestNoteToPayload(
|
||||
{ ...releasePayload, note: 'existing note' },
|
||||
' ',
|
||||
true
|
||||
);
|
||||
|
||||
assert.equal(withDisabledNotes.note, undefined);
|
||||
assert.equal(withBlankNote.note, undefined);
|
||||
});
|
||||
|
||||
it('truncates notes to max length', () => {
|
||||
const overlong = 'a'.repeat(MAX_REQUEST_NOTE_LENGTH + 25);
|
||||
const truncated = truncateRequestNote(overlong);
|
||||
assert.equal(truncated.length, MAX_REQUEST_NOTE_LENGTH);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { bookFromRequestData } from '../utils/requestFulfil.js';
|
||||
|
||||
describe('requestFulfil.bookFromRequestData', () => {
|
||||
it('maps request book data into a ReleaseModal-compatible Book object', () => {
|
||||
const book = bookFromRequestData({
|
||||
title: 'The Pragmatic Programmer',
|
||||
author: 'Andrew Hunt',
|
||||
provider: 'openlibrary',
|
||||
provider_id: 'ol-123',
|
||||
preview: 'https://example.com/cover.jpg',
|
||||
year: 1999,
|
||||
series_name: 'Pragmatic Classics',
|
||||
series_position: '1',
|
||||
subtitle: 'From Journeyman to Master',
|
||||
source_url: 'https://openlibrary.org/books/ol-123',
|
||||
});
|
||||
|
||||
assert.equal(book.id, 'ol-123');
|
||||
assert.equal(book.title, 'The Pragmatic Programmer');
|
||||
assert.equal(book.author, 'Andrew Hunt');
|
||||
assert.equal(book.provider, 'openlibrary');
|
||||
assert.equal(book.provider_id, 'ol-123');
|
||||
assert.equal(book.preview, 'https://example.com/cover.jpg');
|
||||
assert.equal(book.year, '1999');
|
||||
assert.equal(book.series_name, 'Pragmatic Classics');
|
||||
assert.equal(book.series_position, 1);
|
||||
assert.equal(book.subtitle, 'From Journeyman to Master');
|
||||
assert.equal(book.source_url, 'https://openlibrary.org/books/ol-123');
|
||||
});
|
||||
|
||||
it('provides safe fallbacks when request payload fields are missing', () => {
|
||||
const book = bookFromRequestData({});
|
||||
|
||||
assert.equal(book.id, 'Unknown title');
|
||||
assert.equal(book.title, 'Unknown title');
|
||||
assert.equal(book.author, 'Unknown author');
|
||||
assert.equal(book.provider, undefined);
|
||||
assert.equal(book.provider_id, undefined);
|
||||
assert.equal(book.series_position, undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import * as assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import type { Book, CreateRequestPayload, Release } from '../types/index.js';
|
||||
import {
|
||||
buildDirectRequestPayload,
|
||||
buildMetadataBookRequestData,
|
||||
buildReleaseDataFromMetadataRelease,
|
||||
getRequestSuccessMessage,
|
||||
toContentType,
|
||||
} from '../utils/requestPayload.js';
|
||||
|
||||
const baseBook: Book = {
|
||||
id: 'book-1',
|
||||
title: 'Example Title',
|
||||
author: 'Example Author',
|
||||
provider: 'openlibrary',
|
||||
provider_id: 'ol-1',
|
||||
source: 'direct_download',
|
||||
preview: 'https://example.com/cover.jpg',
|
||||
};
|
||||
|
||||
const baseRelease: Release = {
|
||||
source: 'prowlarr',
|
||||
source_id: 'release-1',
|
||||
title: 'Example Title [EPUB]',
|
||||
format: 'epub',
|
||||
size: '2 MB',
|
||||
};
|
||||
|
||||
describe('requestPayload utilities', () => {
|
||||
it('normalizes content type values', () => {
|
||||
assert.equal(toContentType('audiobook'), 'audiobook');
|
||||
assert.equal(toContentType('AUDIOBOOK'), 'audiobook');
|
||||
assert.equal(toContentType('ebook'), 'ebook');
|
||||
assert.equal(toContentType('something-else'), 'ebook');
|
||||
});
|
||||
|
||||
it('creates direct request payload at release level for request_release mode', () => {
|
||||
const payload = buildDirectRequestPayload(baseBook, 'request_release');
|
||||
|
||||
assert.equal(payload.context.request_level, 'release');
|
||||
assert.equal(payload.context.source, 'direct_download');
|
||||
assert.equal(payload.context.content_type, 'ebook');
|
||||
assert.ok(payload.release_data);
|
||||
assert.equal(payload.release_data?.source, 'direct_download');
|
||||
});
|
||||
|
||||
it('creates direct request payload at book level for request_book mode', () => {
|
||||
const payload = buildDirectRequestPayload(baseBook, 'request_book');
|
||||
|
||||
assert.equal(payload.context.request_level, 'book');
|
||||
assert.equal(payload.context.source, 'direct_download');
|
||||
assert.equal(payload.context.content_type, 'ebook');
|
||||
assert.equal(payload.release_data, null);
|
||||
});
|
||||
|
||||
it('builds metadata book + release payload fragments', () => {
|
||||
const bookData = buildMetadataBookRequestData(baseBook, 'ebook');
|
||||
const releaseData = buildReleaseDataFromMetadataRelease(baseBook, baseRelease, 'ebook');
|
||||
|
||||
assert.equal(bookData.provider, 'openlibrary');
|
||||
assert.equal(bookData.provider_id, 'ol-1');
|
||||
assert.equal(bookData.content_type, 'ebook');
|
||||
assert.equal(releaseData.source, 'prowlarr');
|
||||
assert.equal(releaseData.format, 'epub');
|
||||
assert.equal(releaseData.content_type, 'ebook');
|
||||
});
|
||||
|
||||
it('builds success toast message from payload title with fallback', () => {
|
||||
const payloadWithBookTitle: CreateRequestPayload = {
|
||||
book_data: { title: 'Book From Metadata' },
|
||||
release_data: { title: 'Book From Release' },
|
||||
context: {
|
||||
source: 'prowlarr',
|
||||
content_type: 'ebook',
|
||||
request_level: 'release',
|
||||
},
|
||||
};
|
||||
|
||||
const payloadWithReleaseTitleOnly: CreateRequestPayload = {
|
||||
book_data: {},
|
||||
release_data: { title: 'Release Only Title' },
|
||||
context: {
|
||||
source: 'prowlarr',
|
||||
content_type: 'ebook',
|
||||
request_level: 'release',
|
||||
},
|
||||
};
|
||||
|
||||
const payloadUntitled: CreateRequestPayload = {
|
||||
book_data: {},
|
||||
release_data: {},
|
||||
context: {
|
||||
source: 'prowlarr',
|
||||
content_type: 'ebook',
|
||||
request_level: 'release',
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(getRequestSuccessMessage(payloadWithBookTitle), 'Request submitted: Book From Metadata');
|
||||
assert.equal(getRequestSuccessMessage(payloadWithReleaseTitleOnly), 'Request submitted: Release Only Title');
|
||||
assert.equal(getRequestSuccessMessage(payloadUntitled), 'Request submitted: Untitled');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import * as assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import {
|
||||
RequestPolicyCache,
|
||||
resolveDefaultModeFromPolicy,
|
||||
resolveSourceModeFromPolicy,
|
||||
} from '../hooks/requestPolicyCore.js';
|
||||
import type { RequestPolicyResponse } from '../types/index.js';
|
||||
|
||||
const makePolicy = (overrides: Partial<RequestPolicyResponse> = {}): RequestPolicyResponse => ({
|
||||
requests_enabled: true,
|
||||
is_admin: false,
|
||||
allow_notes: true,
|
||||
defaults: {
|
||||
ebook: 'download',
|
||||
audiobook: 'request_release',
|
||||
},
|
||||
rules: [],
|
||||
source_modes: [
|
||||
{
|
||||
source: 'direct_download',
|
||||
supported_content_types: ['ebook'],
|
||||
modes: {
|
||||
ebook: 'request_release',
|
||||
},
|
||||
},
|
||||
{
|
||||
source: 'prowlarr',
|
||||
supported_content_types: ['ebook', 'audiobook'],
|
||||
modes: {
|
||||
ebook: 'download',
|
||||
audiobook: 'blocked',
|
||||
},
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('requestPolicyCore mode resolution', () => {
|
||||
it('resolves default and source modes from policy payload', () => {
|
||||
const policy = makePolicy({
|
||||
defaults: {
|
||||
ebook: 'request_book',
|
||||
audiobook: 'request_release',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(resolveDefaultModeFromPolicy(policy, false, 'ebook'), 'request_book');
|
||||
assert.equal(resolveDefaultModeFromPolicy(policy, false, 'audiobook'), 'request_release');
|
||||
assert.equal(resolveSourceModeFromPolicy(policy, false, 'prowlarr', 'audiobook'), 'blocked');
|
||||
assert.equal(resolveSourceModeFromPolicy(policy, false, 'unknown', 'audiobook'), 'request_release');
|
||||
});
|
||||
|
||||
it('short-circuits to download for admins and requests-disabled policy', () => {
|
||||
const blockedPolicy = makePolicy({
|
||||
requests_enabled: false,
|
||||
defaults: {
|
||||
ebook: 'blocked',
|
||||
audiobook: 'blocked',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(resolveDefaultModeFromPolicy(blockedPolicy, false, 'ebook'), 'download');
|
||||
assert.equal(resolveSourceModeFromPolicy(blockedPolicy, false, 'prowlarr', 'audiobook'), 'download');
|
||||
assert.equal(resolveDefaultModeFromPolicy(makePolicy(), true, 'ebook'), 'download');
|
||||
assert.equal(resolveSourceModeFromPolicy(makePolicy(), true, 'prowlarr', 'audiobook'), 'download');
|
||||
});
|
||||
|
||||
it('falls back to wildcard rules when source_modes entry is missing', () => {
|
||||
const policy = makePolicy({
|
||||
defaults: {
|
||||
ebook: 'download',
|
||||
audiobook: 'request_release',
|
||||
},
|
||||
source_modes: [],
|
||||
rules: [{ source: '*', content_type: 'ebook', mode: 'request_release' }],
|
||||
});
|
||||
|
||||
assert.equal(resolveSourceModeFromPolicy(policy, false, 'mystery_source', 'ebook'), 'request_release');
|
||||
});
|
||||
|
||||
it('caps wildcard rule results to the content default ceiling', () => {
|
||||
const policy = makePolicy({
|
||||
defaults: {
|
||||
ebook: 'request_release',
|
||||
audiobook: 'request_release',
|
||||
},
|
||||
source_modes: [],
|
||||
rules: [{ source: '*', content_type: 'ebook', mode: 'download' }],
|
||||
});
|
||||
|
||||
assert.equal(resolveSourceModeFromPolicy(policy, false, 'mystery_source', 'ebook'), 'request_release');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RequestPolicyCache', () => {
|
||||
it('uses TTL cache for non-forced refresh and refetches after ttl/force', async () => {
|
||||
const originalNow = Date.now;
|
||||
let now = 1_000_000;
|
||||
Date.now = () => now;
|
||||
try {
|
||||
const first = makePolicy();
|
||||
const second = makePolicy({
|
||||
defaults: { ebook: 'request_book', audiobook: 'request_release' },
|
||||
});
|
||||
const third = makePolicy({
|
||||
defaults: { ebook: 'blocked', audiobook: 'blocked' },
|
||||
});
|
||||
|
||||
let fetchCount = 0;
|
||||
const fetcher = async (): Promise<RequestPolicyResponse> => {
|
||||
fetchCount += 1;
|
||||
if (fetchCount === 1) return first;
|
||||
if (fetchCount === 2) return second;
|
||||
return third;
|
||||
};
|
||||
|
||||
const cache = new RequestPolicyCache(fetcher, 60_000);
|
||||
|
||||
const initial = await cache.refresh({ enabled: true, isAdmin: false });
|
||||
assert.deepEqual(initial, first);
|
||||
assert.equal(fetchCount, 1);
|
||||
|
||||
const cached = await cache.refresh({ enabled: true, isAdmin: false });
|
||||
assert.deepEqual(cached, first);
|
||||
assert.equal(fetchCount, 1);
|
||||
|
||||
now += 60_001;
|
||||
const afterTtl = await cache.refresh({ enabled: true, isAdmin: false });
|
||||
assert.deepEqual(afterTtl, second);
|
||||
assert.equal(fetchCount, 2);
|
||||
|
||||
const forced = await cache.refresh({ enabled: true, isAdmin: false, force: true });
|
||||
assert.deepEqual(forced, third);
|
||||
assert.equal(fetchCount, 3);
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
}
|
||||
});
|
||||
|
||||
it('deduplicates in-flight refresh calls and resets in no-auth/admin contexts', async () => {
|
||||
let fetchCount = 0;
|
||||
const pendingResolvers: Array<(value: RequestPolicyResponse) => void> = [];
|
||||
const inflightPolicy = makePolicy();
|
||||
|
||||
const fetcher = (): Promise<RequestPolicyResponse> => {
|
||||
fetchCount += 1;
|
||||
return new Promise<RequestPolicyResponse>((resolve) => {
|
||||
pendingResolvers.push(resolve);
|
||||
});
|
||||
};
|
||||
|
||||
const cache = new RequestPolicyCache(fetcher, 60_000);
|
||||
|
||||
const firstPromise = cache.refresh({ enabled: true, isAdmin: false, force: true });
|
||||
const secondPromise = cache.refresh({ enabled: true, isAdmin: false, force: true });
|
||||
assert.equal(fetchCount, 1);
|
||||
assert.equal(pendingResolvers.length, 1);
|
||||
const firstResolver = pendingResolvers.shift();
|
||||
if (!firstResolver) {
|
||||
throw new Error('Missing first in-flight resolver');
|
||||
}
|
||||
firstResolver(inflightPolicy);
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([firstPromise, secondPromise]);
|
||||
assert.deepEqual(firstResult, inflightPolicy);
|
||||
assert.deepEqual(secondResult, inflightPolicy);
|
||||
|
||||
const noAuthResult = await cache.refresh({ enabled: false, isAdmin: false });
|
||||
assert.equal(noAuthResult, null);
|
||||
|
||||
const postResetRefresh = cache.refresh({ enabled: true, isAdmin: false, force: true });
|
||||
assert.equal(fetchCount, 2);
|
||||
const secondResolver = pendingResolvers.shift();
|
||||
if (!secondResolver) {
|
||||
throw new Error('Missing second in-flight resolver');
|
||||
}
|
||||
secondResolver(inflightPolicy);
|
||||
await postResetRefresh;
|
||||
|
||||
const adminResult = await cache.refresh({ enabled: true, isAdmin: true });
|
||||
assert.equal(adminResult, null);
|
||||
});
|
||||
|
||||
it('runs a fresh forced fetch when a non-forced refresh is already in flight', async () => {
|
||||
let fetchCount = 0;
|
||||
const pendingResolvers: Array<() => void> = [];
|
||||
const firstPolicy = makePolicy({
|
||||
defaults: { ebook: 'download', audiobook: 'download' },
|
||||
});
|
||||
const secondPolicy = makePolicy({
|
||||
defaults: { ebook: 'request_book', audiobook: 'request_release' },
|
||||
});
|
||||
|
||||
const fetcher = (): Promise<RequestPolicyResponse> => {
|
||||
fetchCount += 1;
|
||||
const response = fetchCount === 1 ? firstPolicy : secondPolicy;
|
||||
return new Promise<RequestPolicyResponse>((resolve) => {
|
||||
pendingResolvers.push(() => resolve(response));
|
||||
});
|
||||
};
|
||||
|
||||
const cache = new RequestPolicyCache(fetcher, 60_000);
|
||||
|
||||
const nonForcedPromise = cache.refresh({ enabled: true, isAdmin: false });
|
||||
const forcedPromise = cache.refresh({ enabled: true, isAdmin: false, force: true });
|
||||
|
||||
assert.equal(fetchCount, 1);
|
||||
const firstResolver = pendingResolvers.shift();
|
||||
if (!firstResolver) {
|
||||
throw new Error('Missing first in-flight resolver');
|
||||
}
|
||||
firstResolver();
|
||||
|
||||
const nonForcedResult = await nonForcedPromise;
|
||||
assert.deepEqual(nonForcedResult, firstPolicy);
|
||||
assert.equal(fetchCount, 2);
|
||||
|
||||
const secondResolver = pendingResolvers.shift();
|
||||
if (!secondResolver) {
|
||||
throw new Error('Missing second in-flight resolver');
|
||||
}
|
||||
secondResolver();
|
||||
|
||||
const forcedResult = await forcedPromise;
|
||||
assert.deepEqual(forcedResult, secondPolicy);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import * as assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import type { TableFieldConfig } from '../types/settings.js';
|
||||
import {
|
||||
getAllowedMatrixModes,
|
||||
getEffectiveCellMode,
|
||||
mergeRequestPolicyRuleLayers,
|
||||
normalizeExplicitRulesForPersistence,
|
||||
normalizeRequestPolicyDefaults,
|
||||
normalizeRequestPolicyRules,
|
||||
parseSourceCapabilitiesFromRulesField,
|
||||
RequestPolicyRuleRow,
|
||||
} from '../components/settings/users/requestPolicyGridUtils.js';
|
||||
|
||||
const tableFieldFixture: TableFieldConfig = {
|
||||
type: 'TableField',
|
||||
key: 'REQUEST_POLICY_RULES',
|
||||
label: 'Request policy rules',
|
||||
value: [],
|
||||
columns: [
|
||||
{
|
||||
key: 'source',
|
||||
label: 'Source',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'direct_download', label: 'Direct Download' },
|
||||
{ value: 'prowlarr', label: 'Prowlarr' },
|
||||
{ value: 'irc', label: 'IRC' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'content_type',
|
||||
label: 'Content type',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'ebook', label: 'Ebook', childOf: 'direct_download' },
|
||||
{ value: 'ebook', label: 'Ebook', childOf: 'prowlarr' },
|
||||
{ value: 'audiobook', label: 'Audiobook', childOf: 'prowlarr' },
|
||||
{ value: 'ebook', label: 'Ebook', childOf: 'irc' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'mode',
|
||||
label: 'Mode',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'download', label: 'Download' },
|
||||
{ value: 'request_release', label: 'Request Release' },
|
||||
{ value: 'blocked', label: 'Blocked' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('requestPolicyGridUtils', () => {
|
||||
it('parses dynamic source capabilities from rules field metadata', () => {
|
||||
const capabilities = parseSourceCapabilitiesFromRulesField(tableFieldFixture);
|
||||
|
||||
assert.deepEqual(capabilities, [
|
||||
{
|
||||
source: 'direct_download',
|
||||
displayName: 'Direct Download',
|
||||
supportedContentTypes: ['ebook'],
|
||||
},
|
||||
{
|
||||
source: 'prowlarr',
|
||||
displayName: 'Prowlarr',
|
||||
supportedContentTypes: ['ebook', 'audiobook'],
|
||||
},
|
||||
{
|
||||
source: 'irc',
|
||||
displayName: 'IRC',
|
||||
supportedContentTypes: ['ebook'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters allowed matrix modes by default ceiling', () => {
|
||||
assert.deepEqual(getAllowedMatrixModes('download'), ['download', 'request_release', 'blocked']);
|
||||
assert.deepEqual(getAllowedMatrixModes('request_release'), ['request_release', 'blocked']);
|
||||
assert.deepEqual(getAllowedMatrixModes('request_book'), ['blocked']);
|
||||
assert.deepEqual(getAllowedMatrixModes('blocked'), []);
|
||||
});
|
||||
|
||||
it('preserves explicit rules that match inherited values but removes unsupported pairs', () => {
|
||||
const sourceCapabilities = parseSourceCapabilitiesFromRulesField(tableFieldFixture);
|
||||
const defaultModes = normalizeRequestPolicyDefaults({
|
||||
ebook: 'request_release',
|
||||
audiobook: 'download',
|
||||
});
|
||||
|
||||
const baseRules = normalizeRequestPolicyRules([
|
||||
{ source: 'prowlarr', content_type: 'ebook', mode: 'blocked' },
|
||||
]);
|
||||
|
||||
const explicitRules = normalizeRequestPolicyRules([
|
||||
{ source: 'direct_download', content_type: 'ebook', mode: 'request_release' }, // same as inherited default -> kept (explicit intent)
|
||||
{ source: 'prowlarr', content_type: 'ebook', mode: 'blocked' }, // same as inherited global rule -> kept (explicit intent)
|
||||
{ source: 'prowlarr', content_type: 'audiobook', mode: 'request_release' }, // meaningful override -> kept
|
||||
{ source: 'irc', content_type: 'audiobook', mode: 'blocked' }, // unsupported pair -> removed
|
||||
]);
|
||||
|
||||
const persisted = normalizeExplicitRulesForPersistence({
|
||||
explicitRules,
|
||||
baseRules,
|
||||
defaultModes,
|
||||
sourceCapabilities,
|
||||
});
|
||||
|
||||
assert.deepEqual(persisted, [
|
||||
{ source: 'direct_download', content_type: 'ebook', mode: 'request_release' },
|
||||
{ source: 'prowlarr', content_type: 'audiobook', mode: 'request_release' },
|
||||
{ source: 'prowlarr', content_type: 'ebook', mode: 'blocked' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('overlays user rules on top of global rules for effective cell mode', () => {
|
||||
const globalRules: RequestPolicyRuleRow[] = [
|
||||
{ source: 'prowlarr', content_type: 'ebook', mode: 'blocked' },
|
||||
{ source: 'direct_download', content_type: 'ebook', mode: 'request_release' },
|
||||
];
|
||||
const userRules: RequestPolicyRuleRow[] = [
|
||||
{ source: 'direct_download', content_type: 'ebook', mode: 'blocked' },
|
||||
];
|
||||
const mergedRules = mergeRequestPolicyRuleLayers(globalRules, userRules);
|
||||
const defaults = normalizeRequestPolicyDefaults({
|
||||
ebook: 'download',
|
||||
audiobook: 'download',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
getEffectiveCellMode('direct_download', 'ebook', defaults, globalRules, userRules),
|
||||
'blocked'
|
||||
);
|
||||
assert.equal(
|
||||
getEffectiveCellMode('prowlarr', 'ebook', defaults, globalRules, userRules),
|
||||
'blocked'
|
||||
);
|
||||
|
||||
assert.deepEqual(mergedRules, [
|
||||
{ source: 'direct_download', content_type: 'ebook', mode: 'blocked' },
|
||||
{ source: 'prowlarr', content_type: 'ebook', mode: 'blocked' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import * as assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import type { ButtonStateInfo } from '../types/index.js';
|
||||
import {
|
||||
applyDirectPolicyModeToButtonState,
|
||||
applyUniversalPolicyModeToButtonState,
|
||||
} from '../utils/requestPolicyUi.js';
|
||||
|
||||
describe('requestPolicyUi', () => {
|
||||
const baseDownload: ButtonStateInfo = { text: 'Download', state: 'download' };
|
||||
|
||||
it('maps direct mode to request for request_release/request_book', () => {
|
||||
assert.deepEqual(applyDirectPolicyModeToButtonState(baseDownload, 'request_release'), {
|
||||
text: 'Request',
|
||||
state: 'download',
|
||||
});
|
||||
assert.deepEqual(applyDirectPolicyModeToButtonState(baseDownload, 'request_book'), {
|
||||
text: 'Request',
|
||||
state: 'download',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps direct mode to unavailable for blocked', () => {
|
||||
assert.deepEqual(applyDirectPolicyModeToButtonState(baseDownload, 'blocked'), {
|
||||
text: 'Unavailable',
|
||||
state: 'blocked',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves non-download direct states', () => {
|
||||
const queued: ButtonStateInfo = { text: 'Queued', state: 'queued' };
|
||||
assert.equal(applyDirectPolicyModeToButtonState(queued, 'request_release'), queued);
|
||||
});
|
||||
|
||||
it('maps universal mode to request only for request_book', () => {
|
||||
assert.deepEqual(applyUniversalPolicyModeToButtonState(baseDownload, 'request_book'), {
|
||||
text: 'Request',
|
||||
state: 'download',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps universal mode to get for download and request_release', () => {
|
||||
assert.deepEqual(applyUniversalPolicyModeToButtonState(baseDownload, 'download'), {
|
||||
text: 'Get',
|
||||
state: 'download',
|
||||
});
|
||||
assert.deepEqual(applyUniversalPolicyModeToButtonState(baseDownload, 'request_release'), {
|
||||
text: 'Get',
|
||||
state: 'download',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps universal mode to unavailable for blocked and preserves non-download states', () => {
|
||||
assert.deepEqual(applyUniversalPolicyModeToButtonState(baseDownload, 'blocked'), {
|
||||
text: 'Unavailable',
|
||||
state: 'blocked',
|
||||
});
|
||||
|
||||
const complete: ButtonStateInfo = { text: 'Downloaded', state: 'complete' };
|
||||
assert.equal(applyUniversalPolicyModeToButtonState(complete, 'request_book'), complete);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import * as assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { RequestRecord } from '../types/index.js';
|
||||
import { applyRequestUpdateEvent, upsertRequestRecord } from '../hooks/useRequests.helpers.js';
|
||||
|
||||
const makeRequest = (overrides: Partial<RequestRecord> = {}): RequestRecord => ({
|
||||
id: 1,
|
||||
user_id: 100,
|
||||
status: 'pending',
|
||||
source_hint: 'prowlarr',
|
||||
content_type: 'ebook',
|
||||
request_level: 'release',
|
||||
policy_mode: 'request_release',
|
||||
book_data: { title: 'Request Book', author: 'Request Author' },
|
||||
release_data: { source: 'prowlarr', source_id: 'rel-1', title: 'Request Book.epub' },
|
||||
note: null,
|
||||
admin_note: null,
|
||||
reviewed_by: null,
|
||||
reviewed_at: null,
|
||||
created_at: '2026-02-13T10:00:00Z',
|
||||
updated_at: '2026-02-13T10:00:00Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('useRequests helpers', () => {
|
||||
it('upsertRequestRecord prepends new items and keeps latest-first order', () => {
|
||||
const older = makeRequest({ id: 1, created_at: '2026-02-13T09:00:00Z' });
|
||||
const newer = makeRequest({ id: 2, created_at: '2026-02-13T11:00:00Z' });
|
||||
|
||||
const result = upsertRequestRecord([older], newer);
|
||||
|
||||
assert.deepEqual(result.map((row) => row.id), [2, 1]);
|
||||
});
|
||||
|
||||
it('upsertRequestRecord replaces existing items by id', () => {
|
||||
const base = makeRequest({ id: 8, status: 'pending' });
|
||||
const updated = makeRequest({ id: 8, status: 'fulfilled' });
|
||||
|
||||
const result = upsertRequestRecord([base], updated);
|
||||
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0].id, 8);
|
||||
assert.equal(result[0].status, 'fulfilled');
|
||||
});
|
||||
|
||||
it('applyRequestUpdateEvent updates matching request status', () => {
|
||||
const base = makeRequest({ id: 4, status: 'pending' });
|
||||
|
||||
const result = applyRequestUpdateEvent([base], {
|
||||
request_id: 4,
|
||||
status: 'rejected',
|
||||
});
|
||||
|
||||
assert.equal(result.found, true);
|
||||
assert.equal(result.records[0].status, 'rejected');
|
||||
});
|
||||
|
||||
it('applyRequestUpdateEvent no-ops when request is missing', () => {
|
||||
const base = makeRequest({ id: 10, status: 'pending' });
|
||||
|
||||
const result = applyRequestUpdateEvent([base], {
|
||||
request_id: 11,
|
||||
status: 'cancelled',
|
||||
});
|
||||
|
||||
assert.equal(result.found, false);
|
||||
assert.equal(result.records.length, 1);
|
||||
assert.equal(result.records[0].status, 'pending');
|
||||
});
|
||||
});
|
||||
@@ -67,7 +67,15 @@ export interface ActiveDownloadsResponse {
|
||||
}
|
||||
|
||||
// Button states
|
||||
export type ButtonState = 'download' | 'queued' | 'resolving' | 'locating' | 'downloading' | 'complete' | 'error';
|
||||
export type ButtonState =
|
||||
| 'download'
|
||||
| 'queued'
|
||||
| 'resolving'
|
||||
| 'locating'
|
||||
| 'downloading'
|
||||
| 'complete'
|
||||
| 'error'
|
||||
| 'blocked';
|
||||
|
||||
export interface ButtonStateInfo {
|
||||
text: string;
|
||||
@@ -150,6 +158,60 @@ export type MetadataSearchField =
|
||||
// Content type for search (ebook vs audiobook)
|
||||
export type ContentType = 'ebook' | 'audiobook';
|
||||
|
||||
export type RequestPolicyMode = 'download' | 'request_release' | 'request_book' | 'blocked';
|
||||
|
||||
export interface RequestPolicyDefaults {
|
||||
ebook: RequestPolicyMode;
|
||||
audiobook: RequestPolicyMode;
|
||||
}
|
||||
|
||||
export interface RequestPolicySourceMode {
|
||||
source: string;
|
||||
supported_content_types: string[];
|
||||
modes: Record<string, RequestPolicyMode>;
|
||||
}
|
||||
|
||||
export interface RequestPolicyResponse {
|
||||
requests_enabled: boolean;
|
||||
is_admin: boolean;
|
||||
allow_notes: boolean;
|
||||
defaults: RequestPolicyDefaults;
|
||||
rules: Array<Record<string, unknown>>;
|
||||
source_modes: RequestPolicySourceMode[];
|
||||
}
|
||||
|
||||
export interface RequestContextPayload {
|
||||
source: string;
|
||||
content_type: ContentType;
|
||||
request_level: 'book' | 'release';
|
||||
}
|
||||
|
||||
export interface CreateRequestPayload {
|
||||
book_data: Record<string, unknown>;
|
||||
release_data?: Record<string, unknown> | null;
|
||||
note?: string;
|
||||
context: RequestContextPayload;
|
||||
}
|
||||
|
||||
export interface RequestRecord {
|
||||
id: number;
|
||||
user_id: number;
|
||||
status: 'pending' | 'fulfilled' | 'rejected' | 'cancelled';
|
||||
source_hint: string | null;
|
||||
content_type: ContentType;
|
||||
request_level: 'book' | 'release';
|
||||
policy_mode: RequestPolicyMode;
|
||||
book_data: Record<string, unknown> | null;
|
||||
release_data: Record<string, unknown> | null;
|
||||
note: string | null;
|
||||
admin_note: string | null;
|
||||
reviewed_by: number | null;
|
||||
reviewed_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export type BooksOutputMode = 'folder' | 'booklore' | 'email';
|
||||
|
||||
export interface AppConfig {
|
||||
|
||||
@@ -10,6 +10,7 @@ export type FieldType =
|
||||
| 'TagListField'
|
||||
| 'OrderableListField'
|
||||
| 'TableField'
|
||||
| 'CustomComponentField'
|
||||
| 'ActionButton'
|
||||
| 'HeadingField';
|
||||
|
||||
@@ -51,6 +52,7 @@ export interface BaseField {
|
||||
requiresRestart?: boolean; // True if changing this setting requires a container restart
|
||||
userOverridable?: boolean; // True when this field supports per-user overrides
|
||||
universalOnly?: boolean; // Only show in Universal search mode (hide in Direct mode)
|
||||
hiddenInUi?: boolean; // Keep value in schema/save path but hide default renderer
|
||||
}
|
||||
|
||||
// Specific field interfaces
|
||||
@@ -128,10 +130,19 @@ export interface ActionButtonConfig extends BaseField {
|
||||
style: 'default' | 'primary' | 'danger';
|
||||
}
|
||||
|
||||
export interface CustomComponentFieldConfig extends BaseField {
|
||||
type: 'CustomComponentField';
|
||||
component: string; // Frontend custom component registry key
|
||||
bindKeys?: string[]; // Related value keys this component edits
|
||||
boundFields?: SettingsField[]; // Backing value schema edited by this component
|
||||
wrapInFieldWrapper?: boolean; // Whether to render with standard FieldWrapper layout
|
||||
}
|
||||
|
||||
export interface TableFieldColumnOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
childOf?: string;
|
||||
}
|
||||
|
||||
export type TableFieldColumnType = 'text' | 'select' | 'checkbox' | 'path';
|
||||
@@ -143,6 +154,7 @@ export interface TableFieldColumn {
|
||||
placeholder?: string;
|
||||
options?: TableFieldColumnOption[];
|
||||
defaultValue?: string | boolean;
|
||||
filterByField?: string;
|
||||
}
|
||||
|
||||
export interface TableFieldConfig extends BaseField {
|
||||
@@ -158,6 +170,7 @@ export interface HeadingFieldConfig {
|
||||
type: 'HeadingField';
|
||||
title: string;
|
||||
description?: string;
|
||||
descriptionByAuthMode?: Record<string, string>;
|
||||
linkUrl?: string;
|
||||
linkText?: string;
|
||||
showWhen?: ShowWhen; // Conditional visibility based on another field's value
|
||||
@@ -175,6 +188,7 @@ export type SettingsField =
|
||||
| TagListFieldConfig
|
||||
| OrderableListFieldConfig
|
||||
| TableFieldConfig
|
||||
| CustomComponentFieldConfig
|
||||
| ActionButtonConfig
|
||||
| HeadingFieldConfig;
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
export interface ActivityStatusCounts {
|
||||
ongoing: number;
|
||||
completed: number;
|
||||
errored: number;
|
||||
pendingRequests: number;
|
||||
}
|
||||
|
||||
export interface ActivityBadgeState {
|
||||
total: number;
|
||||
colorClass: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const getActivityBadgeState = (
|
||||
statusCounts: ActivityStatusCounts,
|
||||
isAdmin: boolean
|
||||
): ActivityBadgeState | null => {
|
||||
const pendingRequests = isAdmin ? statusCounts.pendingRequests : 0;
|
||||
const total = statusCounts.ongoing + statusCounts.completed + statusCounts.errored + pendingRequests;
|
||||
|
||||
if (total <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let colorClass = 'bg-green-500';
|
||||
if (statusCounts.errored > 0) {
|
||||
colorClass = 'bg-red-500';
|
||||
} else if (statusCounts.ongoing > 0) {
|
||||
colorClass = 'bg-blue-500';
|
||||
} else if (pendingRequests > 0) {
|
||||
colorClass = 'bg-amber-500';
|
||||
}
|
||||
|
||||
const title = isAdmin
|
||||
? `${statusCounts.ongoing} ongoing, ${statusCounts.completed} completed, ${statusCounts.errored} failed, ${pendingRequests} pending requests`
|
||||
: `${statusCounts.ongoing} ongoing, ${statusCounts.completed} completed, ${statusCounts.errored} failed`;
|
||||
|
||||
return {
|
||||
total,
|
||||
colorClass,
|
||||
title,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
const TRACE_STORAGE_KEY = 'SM_POLICY_TRACE';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
setPolicyTrace?: (enabled: boolean) => void;
|
||||
}
|
||||
}
|
||||
|
||||
const readEnabledState = (): boolean => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return window.localStorage.getItem(TRACE_STORAGE_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const isPolicyTraceEnabled = (): boolean => {
|
||||
return readEnabledState();
|
||||
};
|
||||
|
||||
export const setPolicyTraceEnabled = (enabled: boolean): void => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(TRACE_STORAGE_KEY, enabled ? '1' : '0');
|
||||
} catch {
|
||||
// Ignore localStorage access issues.
|
||||
}
|
||||
};
|
||||
|
||||
export const policyTrace = (event: string, payload?: Record<string, unknown>): void => {
|
||||
if (!isPolicyTraceEnabled()) {
|
||||
return;
|
||||
}
|
||||
const timestamp = new Date().toISOString();
|
||||
if (payload) {
|
||||
console.debug(`[policy-trace ${timestamp}] ${event}`, payload);
|
||||
return;
|
||||
}
|
||||
console.debug(`[policy-trace ${timestamp}] ${event}`);
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.setPolicyTrace = setPolicyTraceEnabled;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Book, CreateRequestPayload } from '../types';
|
||||
|
||||
export const MAX_REQUEST_NOTE_LENGTH = 1000;
|
||||
|
||||
const toText = (value: unknown, fallback: string): string => {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
export const formatSourceLabel = (value: unknown): string => {
|
||||
const source = String(value || '').trim();
|
||||
if (!source) {
|
||||
return 'Unknown source';
|
||||
}
|
||||
return source
|
||||
.split('_')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
const buildSeriesLine = (
|
||||
name: string,
|
||||
position: number | null,
|
||||
count: number | null,
|
||||
): string => {
|
||||
if (!name) return '';
|
||||
if (position != null) {
|
||||
return `#${position}${count ? ` of ${count}` : ''} in ${name}`;
|
||||
}
|
||||
return name;
|
||||
};
|
||||
|
||||
export interface RequestConfirmationPreview {
|
||||
title: string;
|
||||
author: string;
|
||||
year: string;
|
||||
seriesLine: string;
|
||||
preview: string;
|
||||
releaseLine: string;
|
||||
}
|
||||
|
||||
export const buildRequestConfirmationPreview = (
|
||||
payload: CreateRequestPayload
|
||||
): RequestConfirmationPreview => {
|
||||
const bookData = payload.book_data || {};
|
||||
const releaseData = payload.release_data || {};
|
||||
const requestLevel = payload.context?.request_level;
|
||||
|
||||
const seriesLine = buildSeriesLine(
|
||||
toText(bookData.series_name, ''),
|
||||
typeof bookData.series_position === 'number' ? bookData.series_position : null,
|
||||
typeof bookData.series_count === 'number' ? bookData.series_count : null,
|
||||
);
|
||||
|
||||
return {
|
||||
title: toText(bookData.title ?? releaseData.title, 'Untitled'),
|
||||
author: toText(bookData.author ?? releaseData.author, 'Unknown author'),
|
||||
year: toText(bookData.year ?? releaseData.year, ''),
|
||||
seriesLine,
|
||||
preview:
|
||||
typeof bookData.preview === 'string'
|
||||
? bookData.preview
|
||||
: typeof releaseData.preview === 'string'
|
||||
? releaseData.preview
|
||||
: '',
|
||||
releaseLine:
|
||||
requestLevel === 'release'
|
||||
? [
|
||||
typeof releaseData.format === 'string' && releaseData.format
|
||||
? releaseData.format.toUpperCase()
|
||||
: null,
|
||||
typeof releaseData.size === 'string' && releaseData.size ? releaseData.size : null,
|
||||
formatSourceLabel(releaseData.source || payload.context?.source),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' | ')
|
||||
: '',
|
||||
};
|
||||
};
|
||||
|
||||
export const truncateRequestNote = (
|
||||
value: string,
|
||||
maxLength: number = MAX_REQUEST_NOTE_LENGTH
|
||||
): string => value.slice(0, maxLength);
|
||||
|
||||
export const enrichPreviewFromBook = (
|
||||
base: RequestConfirmationPreview,
|
||||
book: Book,
|
||||
): RequestConfirmationPreview => {
|
||||
const seriesLine = buildSeriesLine(
|
||||
book.series_name ?? '',
|
||||
book.series_position ?? null,
|
||||
book.series_count ?? null,
|
||||
);
|
||||
if (!seriesLine && !book.year) return base;
|
||||
|
||||
return {
|
||||
...base,
|
||||
seriesLine: seriesLine || base.seriesLine,
|
||||
year: (book.year && !base.year) ? book.year : base.year,
|
||||
};
|
||||
};
|
||||
|
||||
export const applyRequestNoteToPayload = (
|
||||
payload: CreateRequestPayload,
|
||||
note: string,
|
||||
allowNotes: boolean
|
||||
): CreateRequestPayload => {
|
||||
const trimmedNote = note.trim();
|
||||
const nextPayload: CreateRequestPayload = {
|
||||
...payload,
|
||||
};
|
||||
|
||||
if (allowNotes && trimmedNote) {
|
||||
nextPayload.note = trimmedNote;
|
||||
} else {
|
||||
delete nextPayload.note;
|
||||
}
|
||||
|
||||
return nextPayload;
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Book } from '../types';
|
||||
|
||||
const toOptionalText = (value: unknown): string | undefined => {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const toOptionalNumber = (value: unknown): number | undefined => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const asRecord = (value: Record<string, unknown> | null | undefined): Record<string, unknown> => {
|
||||
if (value && typeof value === 'object') {
|
||||
return value;
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
export const bookFromRequestData = (bookData: Record<string, unknown> | null | undefined): Book => {
|
||||
const row = asRecord(bookData);
|
||||
const providerId = toOptionalText(row.provider_id);
|
||||
const title = toOptionalText(row.title) || 'Unknown title';
|
||||
|
||||
return {
|
||||
id: providerId || title || 'request',
|
||||
title,
|
||||
author: toOptionalText(row.author) || 'Unknown author',
|
||||
provider: toOptionalText(row.provider),
|
||||
provider_id: providerId,
|
||||
preview: toOptionalText(row.preview),
|
||||
year: toOptionalText(row.year),
|
||||
series_name: toOptionalText(row.series_name),
|
||||
series_position: toOptionalNumber(row.series_position),
|
||||
subtitle: toOptionalText(row.subtitle),
|
||||
source_url: toOptionalText(row.source_url),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
Book,
|
||||
ContentType,
|
||||
CreateRequestPayload,
|
||||
Release,
|
||||
RequestPolicyMode,
|
||||
} from '../types';
|
||||
|
||||
export const toContentType = (value: ContentType | string): ContentType => {
|
||||
return String(value).trim().toLowerCase() === 'audiobook' ? 'audiobook' : 'ebook';
|
||||
};
|
||||
|
||||
export const buildMetadataBookRequestData = (book: Book, contentType: ContentType) => {
|
||||
return {
|
||||
title: book.title || 'Unknown title',
|
||||
author: book.author || 'Unknown author',
|
||||
content_type: contentType,
|
||||
provider: book.provider || 'metadata',
|
||||
provider_id: book.provider_id || book.id,
|
||||
year: book.year,
|
||||
preview: book.preview,
|
||||
series_name: book.series_name,
|
||||
series_position: book.series_position,
|
||||
series_count: book.series_count,
|
||||
subtitle: book.subtitle,
|
||||
source_url: book.source_url,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildDirectBookRequestData = (book: Book) => {
|
||||
return {
|
||||
title: book.title || 'Unknown title',
|
||||
author: book.author || 'Unknown author',
|
||||
content_type: 'ebook' as const,
|
||||
provider: 'direct_download',
|
||||
provider_id: book.id,
|
||||
year: book.year,
|
||||
format: book.format,
|
||||
size: book.size,
|
||||
preview: book.preview,
|
||||
source: book.source || 'direct_download',
|
||||
source_url: book.source_url,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildReleaseDataFromMetadataRelease = (
|
||||
book: Book,
|
||||
release: Release,
|
||||
contentType: ContentType
|
||||
) => {
|
||||
return {
|
||||
source: release.source,
|
||||
source_id: release.source_id,
|
||||
title: book.title || release.title || 'Unknown title',
|
||||
author: book.author,
|
||||
year: book.year,
|
||||
format: release.format,
|
||||
size: release.size,
|
||||
size_bytes: release.size_bytes,
|
||||
download_url: release.download_url,
|
||||
protocol: release.protocol,
|
||||
indexer: release.indexer,
|
||||
seeders: release.seeders,
|
||||
extra: release.extra,
|
||||
preview: book.preview,
|
||||
content_type: contentType,
|
||||
series_name: book.series_name,
|
||||
series_position: book.series_position,
|
||||
series_count: book.series_count,
|
||||
subtitle: book.subtitle,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildReleaseDataFromDirectBook = (book: Book) => {
|
||||
return {
|
||||
source: 'direct_download',
|
||||
source_id: book.id,
|
||||
title: book.title || 'Unknown title',
|
||||
author: book.author,
|
||||
year: book.year,
|
||||
format: book.format,
|
||||
size: book.size,
|
||||
preview: book.preview,
|
||||
content_type: 'ebook' as const,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildDirectRequestPayload = (
|
||||
book: Book,
|
||||
mode: Extract<RequestPolicyMode, 'request_release' | 'request_book'>
|
||||
): CreateRequestPayload => {
|
||||
const bookData = buildDirectBookRequestData(book);
|
||||
if (mode === 'request_book') {
|
||||
return {
|
||||
book_data: bookData,
|
||||
release_data: null,
|
||||
context: {
|
||||
source: 'direct_download',
|
||||
content_type: 'ebook',
|
||||
request_level: 'book',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
book_data: bookData,
|
||||
release_data: buildReleaseDataFromDirectBook(book),
|
||||
context: {
|
||||
source: 'direct_download',
|
||||
content_type: 'ebook',
|
||||
request_level: 'release',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const getRequestSuccessMessage = (payload: CreateRequestPayload): string => {
|
||||
const bookData = payload.book_data || {};
|
||||
const releaseData = payload.release_data || {};
|
||||
const title =
|
||||
(typeof bookData.title === 'string' && bookData.title.trim()) ||
|
||||
(typeof releaseData.title === 'string' && releaseData.title.trim()) ||
|
||||
'Untitled';
|
||||
return `Request submitted: ${title}`;
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ButtonStateInfo, RequestPolicyMode } from '../types';
|
||||
|
||||
export const applyDirectPolicyModeToButtonState = (
|
||||
baseState: ButtonStateInfo,
|
||||
mode: RequestPolicyMode
|
||||
): ButtonStateInfo => {
|
||||
if (baseState.state !== 'download') {
|
||||
return baseState;
|
||||
}
|
||||
|
||||
if (mode === 'blocked') {
|
||||
return { text: 'Unavailable', state: 'blocked' };
|
||||
}
|
||||
|
||||
if (mode === 'request_release' || mode === 'request_book') {
|
||||
return { text: 'Request', state: 'download' };
|
||||
}
|
||||
|
||||
return baseState;
|
||||
};
|
||||
|
||||
export const applyUniversalPolicyModeToButtonState = (
|
||||
baseState: ButtonStateInfo,
|
||||
mode: RequestPolicyMode
|
||||
): ButtonStateInfo => {
|
||||
if (baseState.state !== 'download') {
|
||||
return baseState;
|
||||
}
|
||||
|
||||
if (mode === 'request_book') {
|
||||
return { text: 'Request', state: 'download' };
|
||||
}
|
||||
|
||||
if (mode === 'blocked') {
|
||||
return { text: 'Unavailable', state: 'blocked' };
|
||||
}
|
||||
|
||||
return { ...baseState, text: 'Get' };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"outDir": "../../.local/frontend-test-dist",
|
||||
"rootDir": "./src",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["node"],
|
||||
"allowImportingTsExtensions": false
|
||||
},
|
||||
"include": [
|
||||
"src/tests/**/*.node.test.ts",
|
||||
"src/hooks/requestPolicyCore.ts",
|
||||
"src/utils/requestPolicyUi.ts",
|
||||
"src/types/index.ts"
|
||||
]
|
||||
}
|
||||
@@ -22,8 +22,13 @@ export default defineConfig(({ command }) => ({
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
// Note: Socket.IO connects directly to backend (port 8084) in dev mode
|
||||
// to avoid Vite WebSocket proxy issues. No proxy needed here.
|
||||
// Proxy Socket.IO so websocket polling/upgrades share the same origin/cookies as /api.
|
||||
'/socket.io': {
|
||||
target: 'http://localhost:8084',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
+20
-118
@@ -2,7 +2,7 @@
|
||||
Tests for security configuration and migration.
|
||||
|
||||
Tests the security settings registration, migration from old settings,
|
||||
and builtin credential handling/synchronization.
|
||||
and current on-save guard behavior.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -11,7 +11,6 @@ from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from werkzeug.security import check_password_hash
|
||||
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
@@ -313,136 +312,39 @@ class TestSecuritySettings:
|
||||
assert action.show_when == {"field": "AUTH_METHOD", "value": "builtin"}
|
||||
|
||||
|
||||
class TestPasswordValidation:
|
||||
"""Tests for password validation in the on_save handler."""
|
||||
class TestSecurityOnSave:
|
||||
"""Tests for current security on-save guard behavior."""
|
||||
|
||||
def test_on_save_validates_password_match(self):
|
||||
from shelfmark.config.security import _on_save_security
|
||||
|
||||
values = {
|
||||
"AUTH_METHOD": "builtin",
|
||||
"BUILTIN_USERNAME": "admin",
|
||||
"BUILTIN_PASSWORD": "password123",
|
||||
"BUILTIN_PASSWORD_CONFIRM": "different_password",
|
||||
}
|
||||
|
||||
result = _on_save_security(values)
|
||||
|
||||
assert result["error"] is True
|
||||
assert "do not match" in result["message"]
|
||||
|
||||
def test_on_save_validates_password_length(self):
|
||||
from shelfmark.config.security import _on_save_security
|
||||
|
||||
values = {
|
||||
"AUTH_METHOD": "builtin",
|
||||
"BUILTIN_USERNAME": "admin",
|
||||
"BUILTIN_PASSWORD": "abc",
|
||||
"BUILTIN_PASSWORD_CONFIRM": "abc",
|
||||
}
|
||||
|
||||
result = _on_save_security(values)
|
||||
|
||||
assert result["error"] is True
|
||||
assert "at least 4 characters" in result["message"]
|
||||
|
||||
def test_on_save_requires_username_with_password(self):
|
||||
from shelfmark.config.security import _on_save_security
|
||||
|
||||
values = {
|
||||
"AUTH_METHOD": "builtin",
|
||||
"BUILTIN_PASSWORD": "password123",
|
||||
"BUILTIN_PASSWORD_CONFIRM": "password123",
|
||||
}
|
||||
|
||||
result = _on_save_security(values)
|
||||
|
||||
assert result["error"] is True
|
||||
assert "Username cannot be empty" in result["message"]
|
||||
|
||||
def test_on_save_hashes_password(self, tmp_path, monkeypatch):
|
||||
def test_on_save_passthrough_for_non_oidc(self, tmp_path, monkeypatch):
|
||||
from shelfmark.config.security import _on_save_security
|
||||
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
||||
values = {"AUTH_METHOD": "builtin", "PROXY_AUTH_USER_HEADER": "X-Auth-User"}
|
||||
|
||||
values = {
|
||||
"AUTH_METHOD": "builtin",
|
||||
"BUILTIN_USERNAME": "admin",
|
||||
"BUILTIN_PASSWORD": "password123",
|
||||
"BUILTIN_PASSWORD_CONFIRM": "password123",
|
||||
}
|
||||
|
||||
result = _on_save_security(values)
|
||||
result = _on_save_security(values.copy())
|
||||
|
||||
assert result["error"] is False
|
||||
assert "BUILTIN_PASSWORD_HASH" in result["values"]
|
||||
assert "BUILTIN_PASSWORD" not in result["values"]
|
||||
assert "BUILTIN_PASSWORD_CONFIRM" not in result["values"]
|
||||
assert result["values"]["BUILTIN_PASSWORD_HASH"] != "password123"
|
||||
assert result["values"] == values
|
||||
|
||||
def test_on_save_preserves_existing_hash_when_no_password(self):
|
||||
def test_on_save_blocks_oidc_without_local_admin(self, tmp_path, monkeypatch):
|
||||
from shelfmark.config.security import _on_save_security
|
||||
|
||||
with patch("shelfmark.config.security.load_config_file") as mock_load:
|
||||
mock_load.return_value = {"BUILTIN_PASSWORD_HASH": "existing_hash"}
|
||||
|
||||
values = {
|
||||
"AUTH_METHOD": "builtin",
|
||||
"BUILTIN_USERNAME": "admin",
|
||||
}
|
||||
|
||||
result = _on_save_security(values)
|
||||
|
||||
assert result["error"] is False
|
||||
assert result["values"]["BUILTIN_PASSWORD_HASH"] == "existing_hash"
|
||||
|
||||
|
||||
class TestBuiltinAdminSync:
|
||||
"""Builtin credential save should create/update a local admin user."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_user_db(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
||||
self.user_db = UserDB(str(tmp_path / "users.db"))
|
||||
self.user_db.initialize()
|
||||
UserDB(str(tmp_path / "users.db")).initialize()
|
||||
|
||||
def test_on_save_builtin_creates_local_admin(self):
|
||||
result = _on_save_security({"AUTH_METHOD": "oidc"})
|
||||
|
||||
assert result["error"] is True
|
||||
assert "local admin" in result["message"].lower()
|
||||
|
||||
def test_on_save_allows_oidc_with_local_password_admin(self, tmp_path, monkeypatch):
|
||||
from shelfmark.config.security import _on_save_security
|
||||
|
||||
values = {
|
||||
"AUTH_METHOD": "builtin",
|
||||
"BUILTIN_USERNAME": "admin",
|
||||
"BUILTIN_PASSWORD": "password123",
|
||||
"BUILTIN_PASSWORD_CONFIRM": "password123",
|
||||
}
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
||||
user_db = UserDB(str(tmp_path / "users.db"))
|
||||
user_db.initialize()
|
||||
user_db.create_user(username="admin", password_hash="hash", role="admin")
|
||||
|
||||
result = _on_save_security(values)
|
||||
result = _on_save_security({"AUTH_METHOD": "oidc"})
|
||||
|
||||
assert result["error"] is False
|
||||
user = self.user_db.get_user(username="admin")
|
||||
assert user is not None
|
||||
assert user["role"] == "admin"
|
||||
assert user["auth_source"] == "builtin"
|
||||
assert check_password_hash(user["password_hash"], "password123")
|
||||
|
||||
def test_on_save_builtin_updates_existing_user(self):
|
||||
from shelfmark.config.security import _on_save_security
|
||||
|
||||
existing = self.user_db.create_user(username="admin", role="user")
|
||||
assert existing["role"] == "user"
|
||||
|
||||
values = {
|
||||
"AUTH_METHOD": "builtin",
|
||||
"BUILTIN_USERNAME": "admin",
|
||||
"BUILTIN_PASSWORD": "newpassword",
|
||||
"BUILTIN_PASSWORD_CONFIRM": "newpassword",
|
||||
}
|
||||
|
||||
result = _on_save_security(values)
|
||||
|
||||
assert result["error"] is False
|
||||
user = self.user_db.get_user(username="admin")
|
||||
assert user is not None
|
||||
assert user["role"] == "admin"
|
||||
assert user["auth_source"] == "builtin"
|
||||
assert check_password_hash(user["password_hash"], "newpassword")
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
"""Tests for users/request settings registration."""
|
||||
|
||||
from shelfmark.config import users_settings as users_settings_module
|
||||
import shelfmark.config.users_settings # noqa: F401
|
||||
from shelfmark.core import settings_registry
|
||||
|
||||
|
||||
def _field_map(tab_name: str):
|
||||
tab = settings_registry.get_settings_tab(tab_name)
|
||||
assert tab is not None
|
||||
return {field.key: field for field in tab.fields if hasattr(field, "key")}
|
||||
|
||||
|
||||
def test_users_tab_is_renamed_to_users_and_requests():
|
||||
tab = settings_registry.get_settings_tab("users")
|
||||
assert tab is not None
|
||||
assert tab.display_name == "Users & Requests"
|
||||
|
||||
|
||||
def test_users_tab_registers_request_policy_fields():
|
||||
fields = _field_map("users")
|
||||
expected_keys = {
|
||||
"users_management",
|
||||
"REQUESTS_ENABLED",
|
||||
"request_policy_editor",
|
||||
"MAX_PENDING_REQUESTS_PER_USER",
|
||||
"REQUESTS_ALLOW_NOTES",
|
||||
}
|
||||
assert expected_keys.issubset(set(fields))
|
||||
assert "REQUEST_POLICY_DEFAULT_EBOOK" not in fields
|
||||
assert "REQUEST_POLICY_DEFAULT_AUDIOBOOK" not in fields
|
||||
assert "REQUEST_POLICY_RULES" not in fields
|
||||
|
||||
|
||||
def test_users_heading_contains_auth_mode_specific_descriptions():
|
||||
fields = _field_map("users")
|
||||
heading = fields["users_heading"]
|
||||
|
||||
assert heading.description_by_auth_mode["builtin"] == (
|
||||
"Create and manage user accounts directly. Passwords are stored locally and users sign in "
|
||||
"with their username and password."
|
||||
)
|
||||
assert heading.description_by_auth_mode["oidc"] == (
|
||||
"Users sign in through your identity provider. New accounts can be created automatically on "
|
||||
"first login when auto-provisioning is enabled, or you can pre-create users here and they\u2019ll "
|
||||
"be linked by email on first sign-in."
|
||||
)
|
||||
assert heading.description_by_auth_mode["proxy"] == (
|
||||
"Users are authenticated by your reverse proxy. Accounts are automatically created on first "
|
||||
"sign-in. If a local user with a matching username already exists, it will be linked instead."
|
||||
)
|
||||
assert heading.description_by_auth_mode["cwa"] == (
|
||||
"User accounts are synced from your Calibre-Web database. Users are matched by email, and new "
|
||||
"accounts are created here when new CWA users are found."
|
||||
)
|
||||
assert heading.description_by_auth_mode["none"] == (
|
||||
"Authentication is disabled. Anyone can access Shelfmark without signing in."
|
||||
)
|
||||
|
||||
|
||||
def test_request_policy_fields_are_user_overridable():
|
||||
overridable_map = settings_registry.get_user_overridable_fields(tab_name="users")
|
||||
expected_keys = {
|
||||
"REQUESTS_ENABLED",
|
||||
"REQUEST_POLICY_DEFAULT_EBOOK",
|
||||
"REQUEST_POLICY_DEFAULT_AUDIOBOOK",
|
||||
"REQUEST_POLICY_RULES",
|
||||
"MAX_PENDING_REQUESTS_PER_USER",
|
||||
"REQUESTS_ALLOW_NOTES",
|
||||
}
|
||||
assert expected_keys.issubset(set(overridable_map))
|
||||
assert "RESTRICT_SETTINGS_TO_ADMIN" not in overridable_map
|
||||
|
||||
|
||||
def test_users_tab_registers_custom_components():
|
||||
fields = _field_map("users")
|
||||
|
||||
users_management = fields["users_management"]
|
||||
request_policy_editor = fields["request_policy_editor"]
|
||||
|
||||
assert users_management.get_field_type() == "CustomComponentField"
|
||||
assert users_management.component == "users_management"
|
||||
|
||||
assert request_policy_editor.get_field_type() == "CustomComponentField"
|
||||
assert request_policy_editor.component == "request_policy_grid"
|
||||
assert request_policy_editor.wrap_in_field_wrapper is True
|
||||
assert request_policy_editor.get_bind_keys() == [
|
||||
"REQUEST_POLICY_DEFAULT_EBOOK",
|
||||
"REQUEST_POLICY_DEFAULT_AUDIOBOOK",
|
||||
"REQUEST_POLICY_RULES",
|
||||
]
|
||||
assert [field.key for field in request_policy_editor.value_fields] == [
|
||||
"REQUEST_POLICY_DEFAULT_EBOOK",
|
||||
"REQUEST_POLICY_DEFAULT_AUDIOBOOK",
|
||||
"REQUEST_POLICY_RULES",
|
||||
]
|
||||
assert request_policy_editor.show_when == {"field": "REQUESTS_ENABLED", "value": True}
|
||||
|
||||
|
||||
def test_request_policy_raw_fields_are_scoped_to_custom_component():
|
||||
fields = _field_map("users")
|
||||
request_policy_editor = fields["request_policy_editor"]
|
||||
|
||||
assert "REQUEST_POLICY_DEFAULT_EBOOK" not in fields
|
||||
assert "REQUEST_POLICY_DEFAULT_AUDIOBOOK" not in fields
|
||||
assert "REQUEST_POLICY_RULES" not in fields
|
||||
assert [field.key for field in request_policy_editor.value_fields] == [
|
||||
"REQUEST_POLICY_DEFAULT_EBOOK",
|
||||
"REQUEST_POLICY_DEFAULT_AUDIOBOOK",
|
||||
"REQUEST_POLICY_RULES",
|
||||
]
|
||||
|
||||
|
||||
def test_request_policy_rules_field_has_expected_columns():
|
||||
fields = _field_map("users")
|
||||
request_policy_editor = fields["request_policy_editor"]
|
||||
rules_field = next(
|
||||
field for field in request_policy_editor.value_fields if field.key == "REQUEST_POLICY_RULES"
|
||||
)
|
||||
|
||||
columns = rules_field.columns() if callable(rules_field.columns) else rules_field.columns
|
||||
column_keys = [column["key"] for column in columns]
|
||||
assert column_keys == ["source", "content_type", "mode"]
|
||||
|
||||
|
||||
def test_request_workflow_dependent_fields_are_gated_by_toggle():
|
||||
fields = _field_map("users")
|
||||
|
||||
assert fields["MAX_PENDING_REQUESTS_PER_USER"].show_when == {
|
||||
"field": "REQUESTS_ENABLED",
|
||||
"value": True,
|
||||
}
|
||||
assert fields["REQUESTS_ALLOW_NOTES"].show_when == {
|
||||
"field": "REQUESTS_ENABLED",
|
||||
"value": True,
|
||||
}
|
||||
|
||||
|
||||
def test_users_tab_serialization_scopes_request_policy_to_bound_fields():
|
||||
tab = settings_registry.get_settings_tab("users")
|
||||
assert tab is not None
|
||||
|
||||
serialized_tab = settings_registry.serialize_tab(tab)
|
||||
serialized_fields = {field["key"]: field for field in serialized_tab["fields"]}
|
||||
|
||||
assert "REQUEST_POLICY_DEFAULT_EBOOK" not in serialized_fields
|
||||
assert "REQUEST_POLICY_DEFAULT_AUDIOBOOK" not in serialized_fields
|
||||
assert "REQUEST_POLICY_RULES" not in serialized_fields
|
||||
|
||||
request_policy_editor = serialized_fields["request_policy_editor"]
|
||||
bound_fields = request_policy_editor.get("boundFields", [])
|
||||
|
||||
assert [field["key"] for field in bound_fields] == [
|
||||
"REQUEST_POLICY_DEFAULT_EBOOK",
|
||||
"REQUEST_POLICY_DEFAULT_AUDIOBOOK",
|
||||
"REQUEST_POLICY_RULES",
|
||||
]
|
||||
assert all(field.get("hiddenInUi") is True for field in bound_fields)
|
||||
assert serialized_fields["users_heading"].get("descriptionByAuthMode", {}).get("builtin")
|
||||
|
||||
|
||||
def test_request_policy_rules_source_options_are_dynamic(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.list_available_sources",
|
||||
lambda: [
|
||||
{
|
||||
"name": "direct_download",
|
||||
"display_name": "Direct Download",
|
||||
"enabled": True,
|
||||
"supported_content_types": ["ebook"],
|
||||
},
|
||||
{
|
||||
"name": "prowlarr",
|
||||
"display_name": "Prowlarr",
|
||||
"enabled": True,
|
||||
"supported_content_types": ["ebook", "audiobook"],
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
columns = users_settings_module._get_request_policy_rule_columns()
|
||||
source_options = columns[0]["options"]
|
||||
|
||||
assert source_options == [
|
||||
{"value": "direct_download", "label": "Direct Download"},
|
||||
{"value": "prowlarr", "label": "Prowlarr"},
|
||||
]
|
||||
|
||||
content_type_column = columns[1]
|
||||
content_type_options = content_type_column["options"]
|
||||
assert content_type_column["filterByField"] == "source"
|
||||
|
||||
assert {"value": "ebook", "label": "Ebook", "childOf": "direct_download"} in content_type_options
|
||||
assert {"value": "ebook", "label": "Ebook", "childOf": "prowlarr"} in content_type_options
|
||||
assert {"value": "audiobook", "label": "Audiobook", "childOf": "prowlarr"} in content_type_options
|
||||
assert {"value": "*", "label": "Any Type (*)", "childOf": "prowlarr"} not in content_type_options
|
||||
assert {"value": "*", "label": "Any Type (*)", "childOf": "direct_download"} not in content_type_options
|
||||
|
||||
mode_options = columns[2]["options"]
|
||||
assert mode_options[0] == {"value": "download", "label": "Download", "description": "Allow direct downloads."}
|
||||
assert {opt["value"] for opt in mode_options} == {"download", "request_release", "blocked"}
|
||||
|
||||
|
||||
def test_on_save_users_rejects_unsupported_source_content_type_pair(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.config.users_settings.validate_policy_rules",
|
||||
lambda rules: (
|
||||
[],
|
||||
["Rule 1: source 'direct_download' does not support content_type 'audiobook'"],
|
||||
),
|
||||
)
|
||||
|
||||
result = users_settings_module._on_save_users(
|
||||
{
|
||||
"REQUEST_POLICY_RULES": [
|
||||
{
|
||||
"source": "direct_download",
|
||||
"content_type": "audiobook",
|
||||
"mode": "request_release",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
assert result["error"] is True
|
||||
assert "does not support content_type" in result["message"]
|
||||
|
||||
|
||||
def test_on_save_users_rejects_blank_source_rule():
|
||||
result = users_settings_module._on_save_users(
|
||||
{
|
||||
"REQUEST_POLICY_RULES": [
|
||||
{
|
||||
"source": "",
|
||||
"content_type": "ebook",
|
||||
"mode": "request_release",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
assert result["error"] is True
|
||||
assert "source is required" in result["message"]
|
||||
|
||||
|
||||
def test_on_save_users_rejects_blank_content_type_rule():
|
||||
result = users_settings_module._on_save_users(
|
||||
{
|
||||
"REQUEST_POLICY_RULES": [
|
||||
{
|
||||
"source": "direct_download",
|
||||
"content_type": "",
|
||||
"mode": "request_release",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
assert result["error"] is True
|
||||
assert "content_type is required" in result["message"]
|
||||
|
||||
|
||||
def test_on_save_users_rejects_blank_mode_rule():
|
||||
result = users_settings_module._on_save_users(
|
||||
{
|
||||
"REQUEST_POLICY_RULES": [
|
||||
{
|
||||
"source": "direct_download",
|
||||
"content_type": "ebook",
|
||||
"mode": "",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
assert result["error"] is True
|
||||
assert "mode is required" in result["message"]
|
||||
|
||||
|
||||
def test_on_save_users_normalizes_rules(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.config.users_settings.validate_policy_rules",
|
||||
lambda rules: (
|
||||
[
|
||||
{"source": "direct_download", "content_type": "ebook", "mode": "request_release"},
|
||||
],
|
||||
[],
|
||||
),
|
||||
)
|
||||
|
||||
result = users_settings_module._on_save_users(
|
||||
{
|
||||
"REQUEST_POLICY_RULES": [
|
||||
{
|
||||
"source": "DIRECT_DOWNLOAD",
|
||||
"content_type": "BOOK",
|
||||
"mode": "REQUEST_RELEASE",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
assert result["error"] is False
|
||||
assert result["values"]["REQUEST_POLICY_RULES"] == [
|
||||
{"source": "direct_download", "content_type": "ebook", "mode": "request_release"},
|
||||
]
|
||||
@@ -491,6 +491,59 @@ class TestAdminUserUpdateEndpoint:
|
||||
settings = user_db.get_user_settings(user["id"])
|
||||
assert settings["DESTINATION_AUDIOBOOK"] == "/audiobooks/alice"
|
||||
|
||||
def test_update_user_settings_accepts_valid_request_policy_rule(self, admin_client, user_db):
|
||||
user = user_db.create_user(username="alice")
|
||||
|
||||
resp = admin_client.put(
|
||||
f"/api/admin/users/{user['id']}",
|
||||
json={
|
||||
"settings": {
|
||||
"REQUEST_POLICY_RULES": [
|
||||
{
|
||||
"source": "prowlarr",
|
||||
"content_type": "audiobook",
|
||||
"mode": "request_release",
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
settings = user_db.get_user_settings(user["id"])
|
||||
assert settings["REQUEST_POLICY_RULES"] == [
|
||||
{
|
||||
"source": "prowlarr",
|
||||
"content_type": "audiobook",
|
||||
"mode": "request_release",
|
||||
}
|
||||
]
|
||||
|
||||
def test_update_user_settings_rejects_invalid_source_content_type_pair(self, admin_client, user_db):
|
||||
user = user_db.create_user(username="alice")
|
||||
|
||||
resp = admin_client.put(
|
||||
f"/api/admin/users/{user['id']}",
|
||||
json={
|
||||
"settings": {
|
||||
"REQUEST_POLICY_RULES": [
|
||||
{
|
||||
"source": "direct_download",
|
||||
"content_type": "audiobook",
|
||||
"mode": "request_release",
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert resp.json["error"] == "Invalid settings payload"
|
||||
assert any(
|
||||
"does not support content_type 'audiobook'" in msg
|
||||
for msg in resp.json["details"]
|
||||
)
|
||||
|
||||
def test_update_settings_merges(self, admin_client, user_db):
|
||||
user = user_db.create_user(username="alice")
|
||||
user_db.set_user_settings(user["id"], {"DESTINATION": "/books/alice"})
|
||||
@@ -515,6 +568,67 @@ class TestAdminUserUpdateEndpoint:
|
||||
assert "settings" in resp.json
|
||||
assert resp.json["settings"]["DESTINATION"] == "/books/alice"
|
||||
|
||||
def test_update_user_settings_null_clears_override(self, admin_client, user_db):
|
||||
user = user_db.create_user(username="alice")
|
||||
user_db.set_user_settings(user["id"], {"DESTINATION": "/books/alice"})
|
||||
|
||||
resp = admin_client.put(
|
||||
f"/api/admin/users/{user['id']}",
|
||||
json={"settings": {"DESTINATION": None}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
settings = user_db.get_user_settings(user["id"])
|
||||
assert settings.get("DESTINATION") is None
|
||||
|
||||
def test_update_user_settings_null_policy_mode_accepted(self, admin_client, user_db):
|
||||
user = user_db.create_user(username="alice")
|
||||
user_db.set_user_settings(user["id"], {"REQUEST_POLICY_DEFAULT_EBOOK": "request_book"})
|
||||
|
||||
resp = admin_client.put(
|
||||
f"/api/admin/users/{user['id']}",
|
||||
json={"settings": {"REQUEST_POLICY_DEFAULT_EBOOK": None}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
settings = user_db.get_user_settings(user["id"])
|
||||
assert settings.get("REQUEST_POLICY_DEFAULT_EBOOK") is None
|
||||
|
||||
def test_update_user_settings_null_policy_rules_accepted(self, admin_client, user_db):
|
||||
user = user_db.create_user(username="alice")
|
||||
user_db.set_user_settings(user["id"], {
|
||||
"REQUEST_POLICY_RULES": [{"source": "prowlarr", "content_type": "audiobook", "mode": "request_release"}],
|
||||
})
|
||||
|
||||
resp = admin_client.put(
|
||||
f"/api/admin/users/{user['id']}",
|
||||
json={"settings": {"REQUEST_POLICY_RULES": None}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
settings = user_db.get_user_settings(user["id"])
|
||||
assert settings.get("REQUEST_POLICY_RULES") is None
|
||||
|
||||
def test_update_user_settings_mixed_null_and_values(self, admin_client, user_db):
|
||||
user = user_db.create_user(username="alice")
|
||||
user_db.set_user_settings(user["id"], {
|
||||
"DESTINATION": "/books/alice",
|
||||
"REQUEST_POLICY_DEFAULT_EBOOK": "request_book",
|
||||
})
|
||||
|
||||
resp = admin_client.put(
|
||||
f"/api/admin/users/{user['id']}",
|
||||
json={"settings": {
|
||||
"DESTINATION": None,
|
||||
"BOOKLORE_LIBRARY_ID": "5",
|
||||
"REQUEST_POLICY_DEFAULT_EBOOK": None,
|
||||
"REQUEST_POLICY_DEFAULT_AUDIOBOOK": "download",
|
||||
}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
settings = user_db.get_user_settings(user["id"])
|
||||
assert settings.get("DESTINATION") is None
|
||||
assert settings["BOOKLORE_LIBRARY_ID"] == "5"
|
||||
assert settings.get("REQUEST_POLICY_DEFAULT_EBOOK") is None
|
||||
assert settings["REQUEST_POLICY_DEFAULT_AUDIOBOOK"] == "download"
|
||||
|
||||
def test_update_user_settings_rejects_unknown_key(self, admin_client, user_db):
|
||||
user = user_db.create_user(username="alice")
|
||||
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Baseline guardrail tests for download API endpoints.
|
||||
|
||||
These tests lock current behavior for `/api/download`, `/api/releases/download`,
|
||||
and `/api/status` so policy work in later phases cannot accidentally change
|
||||
existing contracts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def main_module():
|
||||
"""Import `shelfmark.main` with background startup disabled."""
|
||||
with patch("shelfmark.download.orchestrator.start"):
|
||||
import shelfmark.main as main
|
||||
|
||||
importlib.reload(main)
|
||||
return main
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(main_module):
|
||||
return main_module.app.test_client()
|
||||
|
||||
|
||||
def _set_authenticated_session(
|
||||
client,
|
||||
*,
|
||||
user_id: str = "alice",
|
||||
db_user_id: int | None = 7,
|
||||
is_admin: bool = False,
|
||||
) -> None:
|
||||
with client.session_transaction() as sess:
|
||||
sess["user_id"] = user_id
|
||||
sess["is_admin"] = is_admin
|
||||
if db_user_id is not None:
|
||||
sess["db_user_id"] = db_user_id
|
||||
|
||||
|
||||
class TestDownloadEndpointGuardrails:
|
||||
def test_missing_book_id_returns_400_and_does_not_queue(self, main_module, client):
|
||||
with patch.object(main_module, "get_auth_mode", return_value="none"):
|
||||
with patch.object(main_module.backend, "queue_book") as mock_queue_book:
|
||||
resp = client.get("/api/download")
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert resp.get_json() == {"error": "No book ID provided"}
|
||||
mock_queue_book.assert_not_called()
|
||||
|
||||
def test_success_returns_queued_payload_and_forwards_user_context(self, main_module, client):
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_queue_book(book_id, priority, user_id=None, username=None):
|
||||
captured.update(
|
||||
{
|
||||
"book_id": book_id,
|
||||
"priority": priority,
|
||||
"user_id": user_id,
|
||||
"username": username,
|
||||
}
|
||||
)
|
||||
return True, None
|
||||
|
||||
_set_authenticated_session(
|
||||
client,
|
||||
user_id="alice",
|
||||
db_user_id=42,
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
with patch.object(main_module.backend, "queue_book", side_effect=fake_queue_book):
|
||||
resp = client.get("/api/download?id=book-123&priority=5")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == {"status": "queued", "priority": 5}
|
||||
assert captured == {
|
||||
"book_id": "book-123",
|
||||
"priority": 5,
|
||||
"user_id": 42,
|
||||
"username": "alice",
|
||||
}
|
||||
|
||||
def test_malformed_priority_returns_500_current_behavior(self, main_module, client):
|
||||
with patch.object(main_module, "get_auth_mode", return_value="none"):
|
||||
with patch.object(main_module.backend, "queue_book") as mock_queue_book:
|
||||
resp = client.get("/api/download?id=book-123&priority=high")
|
||||
|
||||
body = resp.get_json()
|
||||
assert resp.status_code == 500
|
||||
assert "invalid literal for int()" in body["error"]
|
||||
mock_queue_book.assert_not_called()
|
||||
|
||||
def test_auth_enabled_without_session_returns_401(self, main_module, client):
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
resp = client.get("/api/download?id=book-123")
|
||||
|
||||
assert resp.status_code == 401
|
||||
assert resp.get_json() == {"error": "Unauthorized"}
|
||||
|
||||
|
||||
class TestReleaseDownloadEndpointGuardrails:
|
||||
def test_empty_json_payload_returns_400(self, main_module, client):
|
||||
with patch.object(main_module, "get_auth_mode", return_value="none"):
|
||||
with patch.object(main_module.backend, "queue_release") as mock_queue_release:
|
||||
resp = client.post("/api/releases/download", json={})
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert resp.get_json() == {"error": "No data provided"}
|
||||
mock_queue_release.assert_not_called()
|
||||
|
||||
def test_missing_source_id_returns_400(self, main_module, client):
|
||||
payload = {
|
||||
"source": "direct_download",
|
||||
"title": "Example",
|
||||
}
|
||||
with patch.object(main_module, "get_auth_mode", return_value="none"):
|
||||
with patch.object(main_module.backend, "queue_release") as mock_queue_release:
|
||||
resp = client.post("/api/releases/download", json=payload)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert resp.get_json() == {"error": "source_id is required"}
|
||||
mock_queue_release.assert_not_called()
|
||||
|
||||
def test_success_returns_queued_payload_and_forwards_user_context(self, main_module, client):
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_queue_release(release_data, priority, user_id=None, username=None):
|
||||
captured.update(
|
||||
{
|
||||
"release_data": release_data,
|
||||
"priority": priority,
|
||||
"user_id": user_id,
|
||||
"username": username,
|
||||
}
|
||||
)
|
||||
return True, None
|
||||
|
||||
_set_authenticated_session(
|
||||
client,
|
||||
user_id="bob",
|
||||
db_user_id=19,
|
||||
is_admin=False,
|
||||
)
|
||||
payload = {
|
||||
"source": "direct_download",
|
||||
"source_id": "release-xyz",
|
||||
"title": "Release Title",
|
||||
"priority": 3,
|
||||
}
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
with patch.object(main_module.backend, "queue_release", side_effect=fake_queue_release):
|
||||
resp = client.post("/api/releases/download", json=payload)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == {"status": "queued", "priority": 3}
|
||||
assert captured["release_data"] == {**payload, "content_type": "ebook"}
|
||||
assert captured["priority"] == 3
|
||||
assert captured["user_id"] == 19
|
||||
assert captured["username"] == "bob"
|
||||
|
||||
def test_missing_content_type_infers_audiobook_from_format(self, main_module, client):
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_queue_release(release_data, priority, user_id=None, username=None):
|
||||
captured.update(
|
||||
{
|
||||
"release_data": release_data,
|
||||
"priority": priority,
|
||||
"user_id": user_id,
|
||||
"username": username,
|
||||
}
|
||||
)
|
||||
return True, None
|
||||
|
||||
payload = {
|
||||
"source": "prowlarr",
|
||||
"source_id": "release-audio",
|
||||
"title": "Audio Title [m4b]",
|
||||
"format": "m4b",
|
||||
"priority": 1,
|
||||
}
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="none"):
|
||||
with patch.object(main_module.backend, "queue_release", side_effect=fake_queue_release):
|
||||
resp = client.post("/api/releases/download", json=payload)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == {"status": "queued", "priority": 1}
|
||||
assert captured["release_data"] == {**payload, "content_type": "audiobook"}
|
||||
assert captured["priority"] == 1
|
||||
|
||||
def test_non_json_payload_returns_500_current_behavior(self, main_module, client):
|
||||
with patch.object(main_module, "get_auth_mode", return_value="none"):
|
||||
with patch.object(main_module.backend, "queue_release") as mock_queue_release:
|
||||
resp = client.post(
|
||||
"/api/releases/download",
|
||||
data="not-json",
|
||||
content_type="text/plain",
|
||||
)
|
||||
|
||||
body = resp.get_json()
|
||||
assert resp.status_code == 500
|
||||
assert "Unsupported Media Type" in body["error"]
|
||||
mock_queue_release.assert_not_called()
|
||||
|
||||
|
||||
class TestStatusEndpointGuardrails:
|
||||
def test_no_auth_allows_without_session_and_returns_status(self, main_module, client):
|
||||
observed: dict[str, object] = {}
|
||||
expected_status = {
|
||||
"queued": {"book-1": {"title": "One"}},
|
||||
"downloading": {},
|
||||
"completed": {},
|
||||
"failed": {},
|
||||
"cancelled": {},
|
||||
}
|
||||
|
||||
def fake_queue_status(user_id=None):
|
||||
observed["user_id"] = user_id
|
||||
return expected_status
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="none"):
|
||||
with patch.object(main_module.backend, "queue_status", side_effect=fake_queue_status):
|
||||
resp = client.get("/api/status")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == expected_status
|
||||
assert observed["user_id"] is None
|
||||
|
||||
def test_auth_enabled_without_session_returns_401(self, main_module, client):
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
resp = client.get("/api/status")
|
||||
|
||||
assert resp.status_code == 401
|
||||
assert resp.get_json() == {"error": "Unauthorized"}
|
||||
|
||||
def test_non_admin_status_is_scoped_to_db_user(self, main_module, client):
|
||||
observed: dict[str, object] = {}
|
||||
|
||||
def fake_queue_status(user_id=None):
|
||||
observed["user_id"] = user_id
|
||||
return {"queued": {}, "downloading": {}, "completed": {}, "failed": {}, "cancelled": {}}
|
||||
|
||||
_set_authenticated_session(
|
||||
client,
|
||||
user_id="reader",
|
||||
db_user_id=55,
|
||||
is_admin=False,
|
||||
)
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
with patch.object(main_module.backend, "queue_status", side_effect=fake_queue_status):
|
||||
resp = client.get("/api/status")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert observed["user_id"] == 55
|
||||
|
||||
def test_admin_status_is_unscoped(self, main_module, client):
|
||||
observed: dict[str, object] = {}
|
||||
|
||||
def fake_queue_status(user_id=None):
|
||||
observed["user_id"] = user_id
|
||||
return {"queued": {}, "downloading": {}, "completed": {}, "failed": {}, "cancelled": {}}
|
||||
|
||||
_set_authenticated_session(
|
||||
client,
|
||||
user_id="admin",
|
||||
db_user_id=1,
|
||||
is_admin=True,
|
||||
)
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
with patch.object(main_module.backend, "queue_status", side_effect=fake_queue_status):
|
||||
resp = client.get("/api/status")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert observed["user_id"] is None
|
||||
@@ -0,0 +1,411 @@
|
||||
"""Tests for request-policy resolution."""
|
||||
|
||||
from shelfmark.core.request_policy import (
|
||||
PolicyMode,
|
||||
filter_request_policy_settings,
|
||||
get_source_content_type_capabilities,
|
||||
merge_request_policy_settings,
|
||||
normalize_content_type,
|
||||
resolve_policy_mode,
|
||||
validate_policy_rules,
|
||||
)
|
||||
|
||||
|
||||
def test_filter_request_policy_settings_uses_uppercase_allowlist_only():
|
||||
settings = {
|
||||
"REQUEST_POLICY_DEFAULT_EBOOK": "download",
|
||||
"REQUEST_POLICY_DEFAULT_AUDIOBOOK": "request_book",
|
||||
"REQUEST_POLICY_RULES": [{"source": "*", "content_type": "*", "mode": "blocked"}],
|
||||
"REQUESTS_ENABLED": True,
|
||||
"REQUESTS_ALLOW_NOTES": False,
|
||||
"MAX_PENDING_REQUESTS_PER_USER": 7,
|
||||
"request_policy_default_ebook": "blocked",
|
||||
"DESTINATION": "/books/alice",
|
||||
}
|
||||
|
||||
filtered = filter_request_policy_settings(settings)
|
||||
|
||||
assert "REQUEST_POLICY_DEFAULT_EBOOK" in filtered
|
||||
assert "REQUEST_POLICY_DEFAULT_AUDIOBOOK" in filtered
|
||||
assert "REQUEST_POLICY_RULES" in filtered
|
||||
assert "REQUESTS_ENABLED" in filtered
|
||||
assert "REQUESTS_ALLOW_NOTES" in filtered
|
||||
assert "MAX_PENDING_REQUESTS_PER_USER" in filtered
|
||||
assert "request_policy_default_ebook" not in filtered
|
||||
assert "DESTINATION" not in filtered
|
||||
|
||||
|
||||
def test_merge_request_policy_settings_applies_user_overrides():
|
||||
global_settings = {
|
||||
"REQUEST_POLICY_DEFAULT_EBOOK": "download",
|
||||
"REQUEST_POLICY_DEFAULT_AUDIOBOOK": "download",
|
||||
"REQUEST_POLICY_RULES": [{"source": "*", "content_type": "*", "mode": "request_book"}],
|
||||
}
|
||||
user_settings = {
|
||||
"REQUEST_POLICY_DEFAULT_EBOOK": "blocked",
|
||||
"DESTINATION": "/books/alice",
|
||||
}
|
||||
|
||||
merged = merge_request_policy_settings(global_settings, user_settings)
|
||||
|
||||
assert merged["REQUEST_POLICY_DEFAULT_EBOOK"] == "blocked"
|
||||
assert merged["REQUEST_POLICY_DEFAULT_AUDIOBOOK"] == "download"
|
||||
assert "DESTINATION" not in merged
|
||||
|
||||
|
||||
def test_merge_request_policy_settings_overlays_user_rules_on_global_rules():
|
||||
global_settings = {
|
||||
"REQUEST_POLICY_DEFAULT_EBOOK": "download",
|
||||
"REQUEST_POLICY_DEFAULT_AUDIOBOOK": "download",
|
||||
"REQUEST_POLICY_RULES": [
|
||||
{"source": "direct_download", "content_type": "ebook", "mode": "download"},
|
||||
{"source": "prowlarr", "content_type": "ebook", "mode": "request_release"},
|
||||
],
|
||||
}
|
||||
user_settings = {
|
||||
"REQUEST_POLICY_RULES": [
|
||||
{"source": "direct_download", "content_type": "ebook", "mode": "blocked"},
|
||||
],
|
||||
}
|
||||
|
||||
merged = merge_request_policy_settings(global_settings, user_settings)
|
||||
|
||||
assert sorted(merged["REQUEST_POLICY_RULES"], key=lambda row: (row["source"], row["content_type"])) == [
|
||||
{"source": "direct_download", "content_type": "ebook", "mode": "blocked"},
|
||||
{"source": "prowlarr", "content_type": "ebook", "mode": "request_release"},
|
||||
]
|
||||
|
||||
|
||||
def test_merge_request_policy_settings_empty_user_rules_preserve_global_rules():
|
||||
global_settings = {
|
||||
"REQUEST_POLICY_DEFAULT_EBOOK": "download",
|
||||
"REQUEST_POLICY_RULES": [
|
||||
{"source": "prowlarr", "content_type": "ebook", "mode": "request_release"},
|
||||
],
|
||||
}
|
||||
user_settings = {
|
||||
"REQUEST_POLICY_RULES": [],
|
||||
}
|
||||
|
||||
merged = merge_request_policy_settings(global_settings, user_settings)
|
||||
|
||||
assert merged["REQUEST_POLICY_RULES"] == [
|
||||
{"source": "prowlarr", "content_type": "ebook", "mode": "request_release"},
|
||||
]
|
||||
|
||||
|
||||
def test_normalize_content_type_defaults_to_ebook_for_unknown_values():
|
||||
assert normalize_content_type(None) == "ebook"
|
||||
assert normalize_content_type("") == "ebook"
|
||||
assert normalize_content_type("book (fiction)") == "ebook"
|
||||
assert normalize_content_type("mystery-value") == "ebook"
|
||||
|
||||
|
||||
def test_normalize_content_type_detects_audiobook_aliases():
|
||||
assert normalize_content_type("audiobook") == "audiobook"
|
||||
assert normalize_content_type("AUDIOBOOKS") == "audiobook"
|
||||
assert normalize_content_type("book (audiobook)") == "audiobook"
|
||||
|
||||
|
||||
def test_resolve_policy_mode_uses_wildcard_precedence():
|
||||
settings = {
|
||||
"REQUEST_POLICY_DEFAULT_EBOOK": "download",
|
||||
"REQUEST_POLICY_DEFAULT_AUDIOBOOK": "download",
|
||||
"REQUEST_POLICY_RULES": [
|
||||
{"source": "*", "content_type": "*", "mode": "blocked"},
|
||||
{"source": "*", "content_type": "ebook", "mode": "request_release"},
|
||||
{"source": "prowlarr", "content_type": "*", "mode": "request_release"},
|
||||
{"source": "prowlarr", "content_type": "ebook", "mode": "download"},
|
||||
],
|
||||
}
|
||||
|
||||
# (prowlarr, ebook) exact match → download
|
||||
assert resolve_policy_mode(
|
||||
source="prowlarr",
|
||||
content_type="ebook",
|
||||
global_settings=settings,
|
||||
) == PolicyMode.DOWNLOAD
|
||||
# (prowlarr, audiobook) → matches (prowlarr, *) → request_release
|
||||
assert resolve_policy_mode(
|
||||
source="prowlarr",
|
||||
content_type="audiobook",
|
||||
global_settings=settings,
|
||||
) == PolicyMode.REQUEST_RELEASE
|
||||
# (irc, ebook) → matches (*, ebook) → request_release
|
||||
assert resolve_policy_mode(
|
||||
source="irc",
|
||||
content_type="ebook",
|
||||
global_settings=settings,
|
||||
) == PolicyMode.REQUEST_RELEASE
|
||||
# (irc, audiobook) → matches (*, *) → blocked
|
||||
assert resolve_policy_mode(
|
||||
source="irc",
|
||||
content_type="audiobook",
|
||||
global_settings=settings,
|
||||
) == PolicyMode.BLOCKED
|
||||
|
||||
|
||||
def test_resolve_policy_mode_uses_content_default_when_no_rule_matches():
|
||||
settings = {
|
||||
"REQUEST_POLICY_DEFAULT_EBOOK": "download",
|
||||
"REQUEST_POLICY_DEFAULT_AUDIOBOOK": "blocked",
|
||||
"REQUEST_POLICY_RULES": [],
|
||||
}
|
||||
|
||||
assert resolve_policy_mode(
|
||||
source="direct_download",
|
||||
content_type="ebook",
|
||||
global_settings=settings,
|
||||
) == PolicyMode.DOWNLOAD
|
||||
assert resolve_policy_mode(
|
||||
source="direct_download",
|
||||
content_type="audiobook",
|
||||
global_settings=settings,
|
||||
) == PolicyMode.BLOCKED
|
||||
|
||||
|
||||
def test_resolve_policy_mode_caps_at_content_type_default_ceiling():
|
||||
"""Matrix rules cannot grant more permissive access than the content-type default."""
|
||||
settings = {
|
||||
"REQUEST_POLICY_DEFAULT_EBOOK": "request_release",
|
||||
"REQUEST_POLICY_DEFAULT_AUDIOBOOK": "request_release",
|
||||
"REQUEST_POLICY_RULES": [
|
||||
{"source": "prowlarr", "content_type": "ebook", "mode": "download"},
|
||||
{"source": "irc", "content_type": "ebook", "mode": "blocked"},
|
||||
],
|
||||
}
|
||||
|
||||
# prowlarr/ebook rule says download, but ceiling is request_release → capped
|
||||
assert resolve_policy_mode(
|
||||
source="prowlarr",
|
||||
content_type="ebook",
|
||||
global_settings=settings,
|
||||
) == PolicyMode.REQUEST_RELEASE
|
||||
# irc/ebook rule says blocked, which is more restrictive than ceiling → stays blocked
|
||||
assert resolve_policy_mode(
|
||||
source="irc",
|
||||
content_type="ebook",
|
||||
global_settings=settings,
|
||||
) == PolicyMode.BLOCKED
|
||||
# no rule for direct_download → falls to ceiling
|
||||
assert resolve_policy_mode(
|
||||
source="direct_download",
|
||||
content_type="ebook",
|
||||
global_settings=settings,
|
||||
) == PolicyMode.REQUEST_RELEASE
|
||||
|
||||
|
||||
def test_resolve_policy_mode_request_book_ceiling_overrides_all_rules():
|
||||
"""When default is request_book, no matrix rule can open the release modal."""
|
||||
settings = {
|
||||
"REQUEST_POLICY_DEFAULT_EBOOK": "request_book",
|
||||
"REQUEST_POLICY_DEFAULT_AUDIOBOOK": "blocked",
|
||||
"REQUEST_POLICY_RULES": [
|
||||
{"source": "prowlarr", "content_type": "ebook", "mode": "download"},
|
||||
{"source": "direct_download", "content_type": "ebook", "mode": "request_release"},
|
||||
],
|
||||
}
|
||||
|
||||
# Both rules try to upgrade beyond request_book → capped
|
||||
assert resolve_policy_mode(
|
||||
source="prowlarr",
|
||||
content_type="ebook",
|
||||
global_settings=settings,
|
||||
) == PolicyMode.REQUEST_BOOK
|
||||
assert resolve_policy_mode(
|
||||
source="direct_download",
|
||||
content_type="ebook",
|
||||
global_settings=settings,
|
||||
) == PolicyMode.REQUEST_BOOK
|
||||
# audiobook default is blocked → even more restrictive ceiling
|
||||
assert resolve_policy_mode(
|
||||
source="prowlarr",
|
||||
content_type="audiobook",
|
||||
global_settings=settings,
|
||||
) == PolicyMode.BLOCKED
|
||||
|
||||
|
||||
def test_resolve_policy_mode_falls_back_to_request_book_when_unset():
|
||||
settings = {
|
||||
"REQUEST_POLICY_DEFAULT_EBOOK": "not-a-mode",
|
||||
"REQUEST_POLICY_DEFAULT_AUDIOBOOK": None,
|
||||
"REQUEST_POLICY_RULES": [],
|
||||
}
|
||||
|
||||
assert resolve_policy_mode(
|
||||
source="direct_download",
|
||||
content_type="ebook",
|
||||
global_settings=settings,
|
||||
) == PolicyMode.REQUEST_BOOK
|
||||
assert resolve_policy_mode(
|
||||
source="prowlarr",
|
||||
content_type="audiobook",
|
||||
global_settings=settings,
|
||||
) == PolicyMode.REQUEST_BOOK
|
||||
|
||||
|
||||
def test_resolve_policy_mode_ignores_invalid_rule_rows():
|
||||
settings = {
|
||||
"REQUEST_POLICY_DEFAULT_EBOOK": "download",
|
||||
"REQUEST_POLICY_RULES": [
|
||||
{"source": "direct_download", "content_type": "ebook", "mode": "request_book"},
|
||||
{"source": "direct_download", "content_type": "ebook", "mode": "not-valid"},
|
||||
{"source": "direct_download", "content_type": "invalid-type", "mode": "blocked"},
|
||||
],
|
||||
}
|
||||
|
||||
resolved = resolve_policy_mode(
|
||||
source="direct_download",
|
||||
content_type="ebook",
|
||||
global_settings=settings,
|
||||
)
|
||||
|
||||
assert resolved == PolicyMode.DOWNLOAD
|
||||
|
||||
|
||||
def test_resolve_policy_mode_uses_user_rules_when_present():
|
||||
global_settings = {
|
||||
"REQUEST_POLICY_DEFAULT_EBOOK": "download",
|
||||
"REQUEST_POLICY_RULES": [
|
||||
{"source": "direct_download", "content_type": "ebook", "mode": "download"},
|
||||
],
|
||||
}
|
||||
user_settings = {
|
||||
"REQUEST_POLICY_RULES": [
|
||||
{"source": "direct_download", "content_type": "ebook", "mode": "blocked"},
|
||||
],
|
||||
}
|
||||
|
||||
resolved = resolve_policy_mode(
|
||||
source="direct_download",
|
||||
content_type="ebook",
|
||||
global_settings=global_settings,
|
||||
user_settings=user_settings,
|
||||
)
|
||||
|
||||
assert resolved == PolicyMode.BLOCKED
|
||||
|
||||
|
||||
def test_resolve_policy_mode_treats_unknown_source_as_wildcard_context():
|
||||
settings = {
|
||||
"REQUEST_POLICY_DEFAULT_EBOOK": "download",
|
||||
"REQUEST_POLICY_RULES": [
|
||||
{"source": "*", "content_type": "ebook", "mode": "request_release"},
|
||||
],
|
||||
}
|
||||
|
||||
resolved = resolve_policy_mode(
|
||||
source=None,
|
||||
content_type="ebook",
|
||||
global_settings=settings,
|
||||
)
|
||||
|
||||
assert resolved == PolicyMode.REQUEST_RELEASE
|
||||
|
||||
|
||||
def test_validate_policy_rules_rejects_unknown_source():
|
||||
rules = [
|
||||
{"source": "not-a-source", "content_type": "ebook", "mode": "download"},
|
||||
]
|
||||
normalized, errors = validate_policy_rules(
|
||||
rules,
|
||||
source_capabilities={
|
||||
"direct_download": {"ebook"},
|
||||
"prowlarr": {"ebook", "audiobook"},
|
||||
},
|
||||
)
|
||||
|
||||
assert normalized == []
|
||||
assert "unknown source" in errors[0]
|
||||
|
||||
|
||||
def test_validate_policy_rules_rejects_blank_source():
|
||||
rules = [
|
||||
{"source": "", "content_type": "ebook", "mode": "download"},
|
||||
]
|
||||
normalized, errors = validate_policy_rules(
|
||||
rules,
|
||||
source_capabilities={
|
||||
"direct_download": {"ebook"},
|
||||
},
|
||||
)
|
||||
|
||||
assert normalized == []
|
||||
assert "source is required" in errors[0]
|
||||
|
||||
|
||||
def test_validate_policy_rules_rejects_unsupported_source_content_type_pair():
|
||||
rules = [
|
||||
{"source": "direct_download", "content_type": "audiobook", "mode": "download"},
|
||||
]
|
||||
normalized, errors = validate_policy_rules(
|
||||
rules,
|
||||
source_capabilities={
|
||||
"direct_download": {"ebook"},
|
||||
"prowlarr": {"ebook", "audiobook"},
|
||||
},
|
||||
)
|
||||
|
||||
assert normalized == []
|
||||
assert "does not support content_type 'audiobook'" in errors[0]
|
||||
|
||||
|
||||
def test_validate_policy_rules_rejects_request_book_in_matrix():
|
||||
rules = [
|
||||
{"source": "direct_download", "content_type": "ebook", "mode": "request_book"},
|
||||
]
|
||||
normalized, errors = validate_policy_rules(
|
||||
rules,
|
||||
source_capabilities={"direct_download": {"ebook"}},
|
||||
)
|
||||
|
||||
assert normalized == []
|
||||
assert "not allowed in matrix rules" in errors[0]
|
||||
|
||||
|
||||
def test_validate_policy_rules_accepts_supported_pairs_and_wildcards():
|
||||
rules = [
|
||||
{"source": "prowlarr", "content_type": "audiobook", "mode": "request_release"},
|
||||
{"source": "direct_download", "content_type": "*", "mode": "blocked"},
|
||||
{"source": "*", "content_type": "ebook", "mode": "download"},
|
||||
]
|
||||
normalized, errors = validate_policy_rules(
|
||||
rules,
|
||||
source_capabilities={
|
||||
"direct_download": {"ebook"},
|
||||
"prowlarr": {"ebook", "audiobook"},
|
||||
"irc": {"ebook"},
|
||||
},
|
||||
)
|
||||
|
||||
assert errors == []
|
||||
assert normalized == [
|
||||
{"source": "prowlarr", "content_type": "audiobook", "mode": "request_release"},
|
||||
{"source": "direct_download", "content_type": "*", "mode": "blocked"},
|
||||
{"source": "*", "content_type": "ebook", "mode": "download"},
|
||||
]
|
||||
|
||||
|
||||
def test_get_source_content_type_capabilities_reads_registry(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.list_available_sources",
|
||||
lambda: [
|
||||
{
|
||||
"name": "direct_download",
|
||||
"display_name": "Direct Download",
|
||||
"enabled": True,
|
||||
"supported_content_types": ["ebook"],
|
||||
},
|
||||
{
|
||||
"name": "prowlarr",
|
||||
"display_name": "Prowlarr",
|
||||
"enabled": True,
|
||||
"supported_content_types": ["ebook", "audiobook"],
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
capabilities = get_source_content_type_capabilities()
|
||||
|
||||
assert capabilities["direct_download"] == {"ebook"}
|
||||
assert capabilities["prowlarr"] == {"ebook", "audiobook"}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user