User admin in the web UI, admin-only log, unread-first OPML feeds
- Settings > Manage users: add an account (password, or none for proxy
sign-in), toggle admin, remove. Backed by GET/POST /api/users and
PATCH/DELETE /api/users/{id}, 403 for non-admins. The only admin
cannot be demoted or removed.
- GET /api/logs is admin-only and the Log button is hidden for others;
the log names every account, feed and failed sign-in.
- Feeds inside an OPML list those with unread items first, in the
sidebar folder and on the subscription's page.
- Deploying is now buildx --push to 192.168.1.130:5000 and recreating
the ipodderx service of the Arcane project content; CLAUDE.md and the
README's Docker section say so.
- Tests: Playwright for user admin, the last-admin guard, 403s for a
non-admin and the unread ordering (new Aardvark Radio fixture); a unit
test for last_admin; the smoke test drives usersModal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173mGu6rK18Ne7UGTwAaVJV
This commit is contained in:
42
CHANGELOG.md
42
CHANGELOG.md
@@ -5,6 +5,48 @@ matters, what was wrong before -- the reasoning is the point, not the diff.
|
||||
|
||||
See [README.md](README.md) for what the thing is, and [docs/](docs/) for how to run it.
|
||||
|
||||
## 2026-09-11 — The log is admin-only
|
||||
|
||||
`GET /api/logs` now returns `403` to anyone who is not an admin, and the page hides the Log button
|
||||
from them. Before this, every signed-in person could read the whole log. That includes every
|
||||
account's name, every feed anyone subscribes to, and every failed sign-in. `/api/events` stays open
|
||||
to everyone, because it carries the scan progress each person's page shows.
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-11 — Managing users from the web
|
||||
|
||||
Settings has a **Manage users…** screen for an admin. From it you can add someone, with a password
|
||||
or with none for someone the proxy signs in, tick or untick Admin, and remove an account. It is
|
||||
backed by `GET/POST /api/users` and `PATCH/DELETE /api/users/{id}`, which return `403` for anyone
|
||||
who is not an admin. The only admin cannot be demoted or removed, because nobody would then be
|
||||
able to manage accounts except from the CLI on the box. Before this, accounts could only be managed
|
||||
with `ipx user`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-11 — Unread feeds first inside an OPML
|
||||
|
||||
An OPML subscription's feeds, both in the sidebar folder and on its own page, now list the ones with
|
||||
unread items first. They were listed alphabetically, so with dozens of feeds the few with anything
|
||||
new were scattered through the list. Within each half the order is still alphabetical. The browser
|
||||
suite's fixture OPML gained a second feed, Aardvark Radio, which sorts first by name and by position,
|
||||
so the new test only passes if unread wins.
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-11 — Deploying is a Docker image
|
||||
|
||||
Production moved from a hand-started daemon in code-server to the `iPodderX` container in the Arcane
|
||||
project `content`. `CLAUDE.md` now deploys by pushing to the registry at `192.168.1.130:5000` and
|
||||
recreating that one service with `docker compose`. The old instructions copied a binary over a
|
||||
process nobody supervised, so it did not come back after a reboot. Two known gaps are gone: the
|
||||
image does support accounts, and the entrypoint drops to `99:100`, so downloads are no longer
|
||||
owned by root. The README's Docker section said `docker compose up -d` builds the image and named the
|
||||
service `ipx`; the compose file pulls from the registry and the service is `ipodderx`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-11 — Documentation
|
||||
|
||||
`PROGRESS.md` became this changelog; the finished step lists moved to an appendix. The README is now
|
||||
|
||||
45
CLAUDE.md
45
CLAUDE.md
@@ -6,31 +6,37 @@ made here.
|
||||
|
||||
## Where things are
|
||||
|
||||
The live install on this machine:
|
||||
Production is the `iPodderX` container on Tower (192.168.1.130), the `ipodderx` service of the
|
||||
Arcane project `content`: `/mnt/fast/arcane/projects/content/compose.yaml`. That file is what runs;
|
||||
`docker-compose.yml` in this repo is a copy, and editing it changes nothing in production.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Binary | `/config/.cargo/bin/ipx` |
|
||||
| Config | `/config/.config/ipx/config.toml` |
|
||||
| Database | `/config/.local/share/ipx/state.db` |
|
||||
| Downloads | `/mnt/user/audio/ipx` |
|
||||
| Web UI | `0.0.0.0:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel |
|
||||
| | Host | In the container |
|
||||
|---|---|---|
|
||||
| Image | `192.168.1.130:5000/ipodderx:latest` | |
|
||||
| Config | `/mnt/fast/appdata/ipodderx/config.toml` | `/config/config.toml` |
|
||||
| Database | `/mnt/user/ipodderx/state.db` | `/data/state.db` |
|
||||
| Downloads | `/mnt/user/ipodderx/downloads` | `/downloads` |
|
||||
| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` |
|
||||
|
||||
Deploying a change is: build, stop, copy, start.
|
||||
Deploying a change is: build and push the image, then pull it and recreate the container.
|
||||
|
||||
```sh
|
||||
cargo build --release
|
||||
pkill -x ipx; sleep 2
|
||||
cp target/release/ipx /config/.cargo/bin/ipx
|
||||
setsid nohup /config/.cargo/bin/ipx --config /config/.config/ipx/config.toml daemon \
|
||||
>/tmp/ipx.log 2>&1 </dev/null &
|
||||
docker buildx build --tag 192.168.1.130:5000/ipodderx:latest . --push
|
||||
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml pull ipodderx
|
||||
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml up -d ipodderx
|
||||
docker logs --tail 20 iPodderX
|
||||
```
|
||||
|
||||
**`pkill -x ipx`, never `pkill -f ipx`.** `-f` matches the shell running the command and kills the
|
||||
session (exit 144). This has happened more than once.
|
||||
**Name the service.** A bare `up -d` recreates every container in `content`, beets and immich
|
||||
included. Run `pull` before `up`, because `up` reuses whatever `latest` the host already has.
|
||||
|
||||
The daemon is not supervised: it will not survive a reboot. `contrib/` has a systemd unit nobody
|
||||
has installed.
|
||||
The healthcheck runs `ipx status` against the control socket, so `(healthy)` in `docker ps` means
|
||||
the worker is alive, not just the web port. The container restarts on its own after a reboot.
|
||||
|
||||
Before the container, ipx ran by hand in code-server, with its files in `/config/.config/ipx/` and
|
||||
`/config/.local/share/ipx/`. Those are still there and the container does not read them. If you run
|
||||
a daemon by hand for testing, stop it with **`pkill -x ipx`, never `pkill -f ipx`**. `-f` matches
|
||||
the shell running the command and kills the session (exit 144). This has happened more than once.
|
||||
|
||||
## Before you touch the page
|
||||
|
||||
@@ -110,10 +116,7 @@ Deliberate simplifications get a `ponytail:` comment naming the ceiling and the
|
||||
|
||||
## Known gaps
|
||||
|
||||
* No user administration in the web UI; `ipx user` on the box only.
|
||||
* Cloudflare's `Cf-Access-Jwt-Assertion` is not verified — ipx trusts the hop plus `trusted_proxies`
|
||||
(documented in [docs/sso.md](docs/sso.md)).
|
||||
* The Docker image predates multi-user; `docker compose build` before relying on it.
|
||||
* Downloads land root-owned; Unraid shares want `99:100`.
|
||||
* A feed's `<description>` subtitle is dropped whenever `content:encoded` exists, which loses
|
||||
Substack-style subtitles.
|
||||
|
||||
10
README.md
10
README.md
@@ -85,11 +85,15 @@ never left behind with nothing explaining where it came from.
|
||||
## Docker
|
||||
|
||||
```sh
|
||||
docker compose up -d # builds the image and starts it
|
||||
docker compose logs -f ipx # the token is printed on first start
|
||||
docker buildx build --tag 192.168.1.130:5000/ipodderx:latest . --push
|
||||
docker compose pull ipodderx && docker compose up -d ipodderx
|
||||
docker compose logs -f ipodderx # the first start prints the default admin password
|
||||
```
|
||||
|
||||
`docker-compose.yml` mounts `./config`, `./data` and a downloads directory, publishes 8099 for the
|
||||
`docker-compose.yml` runs the image from the registry above rather than building it, so build and
|
||||
push first; change the tag in both places to use another registry. It mounts `/config` (config.toml),
|
||||
`/data` (state.db) and `/downloads` from this install's host paths, which you will want to change for
|
||||
yours. It publishes 8099 for the
|
||||
UI and 6881 (TCP **and** UDP -- DHT needs the UDP side), and sets `PUID`/`PGID` to `99:100` so files
|
||||
land owned the way Unraid shares expect. The healthcheck runs `ipx status` through the control
|
||||
socket, so it catches a daemon that is alive but wedged rather than merely one that has died.
|
||||
|
||||
@@ -110,8 +110,9 @@ else a `401`.
|
||||
| `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning |
|
||||
| `POST /api/fetch`, `GET /api/opml`, `POST /api/opml` | |
|
||||
| `GET /api/settings`, `PATCH /api/settings` | admin-only to write |
|
||||
| `GET /api/users`, `POST /api/users`, `PATCH /api/users/{id}`, `DELETE /api/users/{id}` | admin-only; the only admin cannot be demoted or removed |
|
||||
| `GET /api/events` | SSE, the same broadcast the socket carries |
|
||||
| `GET /api/logs` | the ring buffer, with a sequence cursor |
|
||||
| `GET /api/logs` | admin-only; the ring buffer, with a sequence cursor |
|
||||
| `GET /media/{id}` | the file, with Range support so seeking works |
|
||||
|
||||
Show notes are feed-supplied HTML from an untrusted source, sanitized with `ammonia` server-side
|
||||
|
||||
@@ -70,13 +70,17 @@ list with their own read state. Unsubscribing removes it from their list alone;
|
||||
subscriber leaves does the feed stop being scanned, and even then its files and history stay, so
|
||||
re-subscribing does not pull the back catalogue again.
|
||||
|
||||
Accounts are managed from the command line only; there is no user administration in the web UI yet.
|
||||
An admin can do the same from **Settings → Manage users…**: add someone (with a password, or none
|
||||
for someone the proxy signs in), tick or untick Admin, or remove an account. Removing one takes its
|
||||
subscriptions and read state with it; downloaded files stay. The only admin cannot be demoted or
|
||||
removed there, so there is always someone who can manage the rest.
|
||||
|
||||
## Admin
|
||||
|
||||
The first account is an admin. An admin can change global settings (scanning interval, quota,
|
||||
retention, media types, download folder) and a feed's URL, folder and schedule. Everyone else gets
|
||||
the Settings button hidden and a `403` if they ask anyway.
|
||||
retention, media types, download folder), a feed's URL, folder and schedule, and who has an account
|
||||
and who else is an admin, and read the log, which names everyone's feeds and sign-ins. Everyone else
|
||||
gets the Settings and Log buttons hidden and a `403` if they ask anyway.
|
||||
|
||||
```sh
|
||||
ipx user list # the admin column says who
|
||||
|
||||
137
src/web.rs
137
src/web.rs
@@ -74,6 +74,8 @@ pub fn router(state: WebState) -> Router {
|
||||
.route("/api/fetch", post(fetch_now))
|
||||
.route("/api/opml", get(export_opml).post(import_opml))
|
||||
.route("/api/settings", get(get_settings).patch(patch_settings))
|
||||
.route("/api/users", get(list_users).post(add_user))
|
||||
.route("/api/users/{id}", patch(patch_user).delete(remove_user))
|
||||
.route("/api/logs", get(logs))
|
||||
.route("/api/events", get(events))
|
||||
.route("/media/{id}", get(media))
|
||||
@@ -303,6 +305,125 @@ async fn me(user: crate::db::User) -> Json<serde_json::Value> {
|
||||
Json(serde_json::json!({ "name": user.name, "admin": user.is_admin }))
|
||||
}
|
||||
|
||||
// ---- accounts: admin only ----
|
||||
|
||||
fn require_admin(user: &crate::db::User) -> Result<(), ApiError> {
|
||||
if user.is_admin {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiError::forbidden("only an admin manages accounts"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Demoting or removing this account would leave nobody able to manage anyone, and the only
|
||||
/// way back would be `ipx user` on the box.
|
||||
fn last_admin(users: &[crate::db::User], id: i64) -> bool {
|
||||
let admins: Vec<i64> = users.iter().filter(|u| u.is_admin).map(|u| u.id).collect();
|
||||
admins == [id]
|
||||
}
|
||||
|
||||
async fn list_users(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
require_admin(&user)?;
|
||||
let users: Vec<_> = state
|
||||
.ctx
|
||||
.db
|
||||
.users()?
|
||||
.iter()
|
||||
.map(|u| {
|
||||
serde_json::json!({
|
||||
"id": u.id, "name": u.name, "admin": u.is_admin, "password": u.pass_hash.is_some(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(serde_json::json!(users)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NewUser {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
password: String,
|
||||
#[serde(default)]
|
||||
admin: bool,
|
||||
}
|
||||
|
||||
async fn add_user(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
Json(body): Json<NewUser>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
require_admin(&user)?;
|
||||
// The same rules as a name a proxy vouches for, so either way of signing in finds it.
|
||||
let name = crate::auth::name_from_header(&body.name).ok_or_else(|| {
|
||||
ApiError::bad_request("a name is required, without commas, semicolons or line breaks")
|
||||
})?;
|
||||
if state.ctx.db.user_by_name(&name)?.is_some() {
|
||||
return Err(ApiError::bad_request(format!("{name} already exists")));
|
||||
}
|
||||
// No password is someone the proxy signs in, as with `ipx user add --no-password`.
|
||||
let hash = if body.password.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(crate::auth::hash_password(&body.password).map_err(|e| ApiError::bad_request(format!("{e:#}")))?)
|
||||
};
|
||||
state.ctx.db.create_user(&name, hash.as_deref(), body.admin)?;
|
||||
tracing::info!(by = %user.name, user = %name, admin = body.admin, "account added");
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UserPatch {
|
||||
admin: bool,
|
||||
}
|
||||
|
||||
async fn patch_user(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
Path(id): Path<i64>,
|
||||
Json(body): Json<UserPatch>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
require_admin(&user)?;
|
||||
let users = state.ctx.db.users()?;
|
||||
let target = users
|
||||
.iter()
|
||||
.find(|u| u.id == id)
|
||||
.ok_or_else(|| ApiError::bad_request(format!("no account with id {id}")))?;
|
||||
if !body.admin && last_admin(&users, id) {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"{} is the only admin; make someone else an admin first",
|
||||
target.name
|
||||
)));
|
||||
}
|
||||
state.ctx.db.set_admin(id, body.admin)?;
|
||||
tracing::info!(by = %user.name, user = %target.name, admin = body.admin, "admin changed");
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn remove_user(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
require_admin(&user)?;
|
||||
let users = state.ctx.db.users()?;
|
||||
let target = users
|
||||
.iter()
|
||||
.find(|u| u.id == id)
|
||||
.ok_or_else(|| ApiError::bad_request(format!("no account with id {id}")))?;
|
||||
if last_admin(&users, id) {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"{} is the only admin; make someone else an admin first",
|
||||
target.name
|
||||
)));
|
||||
}
|
||||
state.ctx.db.delete_user(id)?;
|
||||
tracing::info!(by = %user.name, user = %target.name, "account removed");
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn login_page() -> Html<&'static str> {
|
||||
Html(include_str!("../web/login.html"))
|
||||
}
|
||||
@@ -473,6 +594,14 @@ impl IntoResponse for ApiError {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_the_last_admin_is_protected() {
|
||||
let u = |id, is_admin| crate::db::User { id, name: format!("u{id}"), pass_hash: None, is_admin };
|
||||
assert!(last_admin(&[u(1, true), u(2, false)], 1));
|
||||
assert!(!last_admin(&[u(1, true), u(2, true)], 1), "another admin remains");
|
||||
assert!(!last_admin(&[u(1, true), u(2, false)], 2), "not an admin at all");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_comparison_rejects_mismatches_and_length_differences() {
|
||||
assert!(constant_time_eq("abc123", "abc123"));
|
||||
@@ -1161,9 +1290,13 @@ struct LogPage {
|
||||
latest: u64,
|
||||
}
|
||||
|
||||
async fn logs(Query(q): Query<LogQuery>) -> Json<LogPage> {
|
||||
async fn logs(user: crate::db::User, Query(q): Query<LogQuery>) -> Result<Json<LogPage>, ApiError> {
|
||||
// The log names every account, every feed and every failed sign-in, not just yours.
|
||||
if !user.is_admin {
|
||||
return Err(ApiError::forbidden("only an admin reads the log"));
|
||||
}
|
||||
let (lines, latest) = crate::logbuf::since(q.after, q.limit.clamp(1, 2000));
|
||||
Json(LogPage { lines, latest })
|
||||
Ok(Json(LogPage { lines, latest }))
|
||||
}
|
||||
|
||||
/// One line per HTTP request, so the web side shows up in the same log as the daemon.
|
||||
|
||||
@@ -44,7 +44,9 @@ const ctx = {
|
||||
json: () => Promise.resolve(
|
||||
String(url).includes('/api/settings')
|
||||
? { schedule: 'every 60m', every_mins: 60, download_dir: '/tmp', max_total_gb: 0, max_age_days: 0 }
|
||||
: []),
|
||||
: String(url).includes('/api/users')
|
||||
? [{ id: 1, name: 'admin', admin: true, password: true }, { id: 2, name: 'sam', admin: false, password: false }]
|
||||
: []),
|
||||
}),
|
||||
EventSource: function () { this.close = () => {}; },
|
||||
MediaMetadata: function () {},
|
||||
@@ -79,6 +81,7 @@ const drive = [
|
||||
['downloadLatestModal', () => ctx.downloadLatestModal(feed)],
|
||||
['removeFeed', () => ctx.removeFeed(feed)],
|
||||
['prefsModal', () => ctx.prefsModal()],
|
||||
['usersModal', () => ctx.usersModal()],
|
||||
['logsModal', () => ctx.logsModal()],
|
||||
// `const S` is not reachable from here: top-level const/let do not become properties
|
||||
// of a vm context the way var and function declarations do.
|
||||
|
||||
@@ -10,7 +10,7 @@ test.beforeEach(async ({ page }) => {
|
||||
test('the page loads and lists the configured feeds', async ({ page }) => {
|
||||
// Regression: a ReferenceError in the script left the shell rendered and the sidebar
|
||||
// empty, with every handler below the error dead. Server-side checks all passed.
|
||||
// Three top-level feeds in the fixture config; the OPML's child is inside a closed folder.
|
||||
// Three top-level feeds in the fixture config; the OPML's children are inside a closed folder.
|
||||
await expect(page.locator('.feed')).toHaveCount(4, { timeout: 15_000 });
|
||||
await expect(page.getByText('Test Show')).toBeVisible();
|
||||
const errors = [];
|
||||
@@ -178,16 +178,50 @@ test('an OPML subscription is a collapsible folder', async ({ page }) => {
|
||||
const chev = page.locator('.feed.group .chev');
|
||||
await expect(chev).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Closed by default: the child is not listed until the folder is opened.
|
||||
// Closed by default: the children are not listed until the folder is opened.
|
||||
const before = await page.locator('.feed').count();
|
||||
await chev.click();
|
||||
await expect(page.locator('.feed')).toHaveCount(before + 1);
|
||||
await expect(page.locator('.feed')).toHaveCount(before + 2);
|
||||
// Scoped to the sidebar: the name also appears as the page heading once selected.
|
||||
await expect(page.locator('#feedlist').getByText('Grouped Show')).toBeVisible();
|
||||
|
||||
// The subscription's own page lists what is inside it.
|
||||
await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click();
|
||||
await expect(page.locator('.childrow')).toHaveCount(1);
|
||||
await expect(page.locator('.childrow')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('inside an OPML, feeds with unread items are listed first', async ({ page }) => {
|
||||
await page.evaluate(() =>
|
||||
fetch('/api/fetch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ feed: 'test-subscriptions', force: true }),
|
||||
}));
|
||||
const chev = page.locator('.feed.group .chev');
|
||||
await expect(chev).toBeVisible({ timeout: 20_000 });
|
||||
await chev.click();
|
||||
const side = page.locator('#feedlist');
|
||||
await expect(side.getByText('Aardvark Radio')).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Other tests change read state, so set it here: Aardvark Radio read, Grouped Show not.
|
||||
// Opening an item reads it; the toggle in the pane below flips it back.
|
||||
await side.getByText('Aardvark Radio').click();
|
||||
const aa = page.locator('.ep', { hasText: 'Aardvark Ep' });
|
||||
await aa.click();
|
||||
await expect(aa).toHaveClass(/read/);
|
||||
await side.getByText('Grouped Show').click();
|
||||
const gs = page.locator('.ep', { hasText: 'Grouped Ep' });
|
||||
await gs.click();
|
||||
await page.locator('#detail button', { hasText: 'Mark unread' }).click();
|
||||
await expect(gs).not.toHaveClass(/read/);
|
||||
|
||||
// Aardvark comes first alphabetically and in the OPML, so only the unread sort puts
|
||||
// Grouped Show above it. The folder stays open across the reload (localStorage).
|
||||
await page.reload();
|
||||
const want = ['Grouped Show', 'Aardvark Radio'];
|
||||
await expect(page.locator('#feedlist .feed.child b')).toHaveText(want, { timeout: 20_000 });
|
||||
await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click();
|
||||
await expect(page.locator('.childrow b')).toHaveText(want);
|
||||
});
|
||||
|
||||
test('marking an OPML subscription read covers the feeds inside it', async ({ page }) => {
|
||||
@@ -283,6 +317,10 @@ test('a second person has their own feeds and their own read state', async ({ br
|
||||
// Sam subscribes to nothing yet, so sees nothing -- the admin's feeds are not theirs.
|
||||
await expect(page.locator('#feedlist')).toContainText('No feeds.');
|
||||
await expect(page.locator('#prefs')).toBeHidden(); // not an admin
|
||||
// Hiding the button is not the guard; the server is.
|
||||
expect((await page.request.get('/api/users')).status()).toBe(403);
|
||||
await expect(page.locator('#logs')).toBeHidden();
|
||||
expect((await page.request.get('/api/logs')).status()).toBe(403);
|
||||
|
||||
// Subscribing to a feed the admin already has costs no second fetch: same feed, same
|
||||
// files, but Sam's own read state.
|
||||
@@ -333,3 +371,44 @@ test('deleting a shared file warns that it is everyone\'s copy', async ({ page }
|
||||
await page.locator('.tabs button', { hasText: 'Downloaded' }).click();
|
||||
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
// Every row says "Admin" on its checkbox, so match the name exactly.
|
||||
const userRow = (page, name) =>
|
||||
page.locator('#modalCard [data-id]').filter({ has: page.locator('b', { hasText: new RegExp(`^${name}$`) }) });
|
||||
|
||||
async function openUsers(page) {
|
||||
await page.locator('#prefs').click();
|
||||
await page.locator('#gusers').click();
|
||||
await expect(userRow(page, 'admin')).toBeVisible();
|
||||
}
|
||||
|
||||
test('an admin adds someone, makes them an admin, and removes them', async ({ page }) => {
|
||||
await openUsers(page);
|
||||
await page.locator('#uname').fill('pat');
|
||||
await page.locator('#upass').fill('patpassword');
|
||||
await page.locator('#uadd').click();
|
||||
const row = userRow(page, 'pat');
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row.locator('[data-a="admin"]')).not.toBeChecked();
|
||||
|
||||
await row.locator('[data-a="admin"]').check();
|
||||
// Not just the box: it has to have reached the database.
|
||||
await expect.poll(async () =>
|
||||
(await (await page.request.get('/api/users')).json()).find(u => u.name === 'pat')?.admin
|
||||
).toBe(true);
|
||||
|
||||
page.once('dialog', d => d.accept());
|
||||
await row.locator('[data-a="rm"]').click();
|
||||
await expect(row).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('the only admin cannot be demoted or removed', async ({ page }) => {
|
||||
await openUsers(page);
|
||||
await userRow(page, 'admin').locator('[data-a="admin"]').click();
|
||||
await expect(page.locator('.toast.bad')).toContainText('only admin');
|
||||
// Redrawn from the server, so the box is back.
|
||||
await expect(userRow(page, 'admin').locator('[data-a="admin"]')).toBeChecked();
|
||||
|
||||
const me = (await (await page.request.get('/api/users')).json()).find(u => u.name === 'admin');
|
||||
expect((await page.request.delete(`/api/users/${me.id}`)).status()).toBe(400);
|
||||
});
|
||||
|
||||
5
tests/ui/fixtures/aardvark.xml
Normal file
5
tests/ui/fixtures/aardvark.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0"?>
|
||||
<rss version="2.0"><channel><title>Aardvark Radio</title><link>http://127.0.0.1:8792/</link>
|
||||
<description>Inside the OPML, and first in it and alphabetically.</description>
|
||||
<item><title>Aardvark Ep</title><guid>aa-1</guid><description>x</description></item>
|
||||
</channel></rss>
|
||||
@@ -1,4 +1,5 @@
|
||||
<opml version="2.0"><head><title>Test Subscriptions</title></head>
|
||||
<body><outline text="Folder">
|
||||
<outline type="rss" text="Aardvark Radio" xmlUrl="http://127.0.0.1:8792/aardvark.xml"/>
|
||||
<outline type="rss" text="Grouped Show" xmlUrl="http://127.0.0.1:8792/other.xml"/>
|
||||
</outline></body></opml>
|
||||
@@ -513,6 +513,9 @@ async function loadFeeds(keepSel){
|
||||
renderFeeds();
|
||||
if(!keepSel && !S.feed && S.feeds.length) selectFeed(S.feeds[0].id);
|
||||
}
|
||||
// An OPML can hold dozens of feeds; the ones with something new go first. sort is stable, so the
|
||||
// server's alphabetical order still holds within each half.
|
||||
const unreadFirst=(a,b)=>(b.unread>0)-(a.unread>0);
|
||||
function renderFeeds(){
|
||||
const q=$('#feedFilter').value.trim().toLowerCase();
|
||||
const list=$('#feedlist'); const top=list.scrollTop; list.innerHTML='';
|
||||
@@ -527,7 +530,8 @@ function renderFeeds(){
|
||||
order.push([f,0]);
|
||||
// A subscription can hold dozens of feeds, so a folder starts closed. Searching
|
||||
// opens them all, or matches inside a closed folder would be invisible.
|
||||
if(expanded.has(f.id) || q) for(const c of shown) if(c.group===f.id) order.push([c,1]);
|
||||
if(expanded.has(f.id) || q)
|
||||
for(const c of shown.filter(c=>c.group===f.id).sort(unreadFirst)) order.push([c,1]);
|
||||
}
|
||||
for(const [f,depth] of order){
|
||||
const kids=shown.filter(c=>c.group===f.id).length;
|
||||
@@ -646,7 +650,7 @@ function renderGroup(f,kids){
|
||||
const draw=()=>{
|
||||
const q=($('#kidSearch').value||'').trim().toLowerCase();
|
||||
const box=$('#kidlist'); box.innerHTML='';
|
||||
const rows=kids.filter(c=>!q||(c.title||c.id).toLowerCase().includes(q));
|
||||
const rows=kids.filter(c=>!q||(c.title||c.id).toLowerCase().includes(q)).sort(unreadFirst);
|
||||
if(!rows.length){ box.innerHTML='<p class="empty">Nothing matches.</p>'; return; }
|
||||
for(const c of rows){
|
||||
const el=document.createElement('div');
|
||||
@@ -1227,9 +1231,13 @@ async function prefsModal(){
|
||||
</div>
|
||||
<span class="hint">Export hands every subscription to another podcast app. Import adds the
|
||||
feeds listed in an OPML you paste in.</span></div>
|
||||
<div class="field"><label>Users</label>
|
||||
<div class="inline"><button class="btn" id="gusers">Manage users…</button></div>
|
||||
<span class="hint">Add and remove the people who can sign in, and choose who is an admin.</span></div>
|
||||
<div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button>
|
||||
<button class="btn primary" id="gsave">Save</button></div>`);
|
||||
$('#gopml').onclick=opmlModal;
|
||||
$('#gusers').onclick=usersModal;
|
||||
$('#gsave').onclick=async()=>{
|
||||
try{
|
||||
await api('/api/settings',{method:'PATCH',body:JSON.stringify({
|
||||
@@ -1244,6 +1252,52 @@ async function prefsModal(){
|
||||
};
|
||||
}
|
||||
|
||||
// Admin only, and the server enforces that: this screen is just the way in.
|
||||
async function usersModal(){
|
||||
const users = await api('/api/users') || [];
|
||||
openModal(`<h3>Users</h3>
|
||||
${users.map(u=>`<div class="inline" data-id="${u.id}" style="margin-bottom:8px">
|
||||
<b style="flex:1;overflow-wrap:anywhere">${esc(u.name)}</b>
|
||||
${u.password?'':'<span class="tag" title="No password: signs in through the proxy">proxy</span>'}
|
||||
<label class="check" style="margin:0"><input type="checkbox" data-a="admin" ${u.admin?'checked':''}> Admin</label>
|
||||
<button class="btn danger" data-a="rm">Remove</button></div>`).join('')}
|
||||
<div class="field" style="margin-top:16px"><label>Add someone</label>
|
||||
<div class="inline">
|
||||
<input type="text" id="uname" placeholder="Name" autocomplete="off" spellcheck="false">
|
||||
<input type="password" id="upass" placeholder="Password" autocomplete="new-password">
|
||||
</div>
|
||||
<label class="check" style="margin-top:8px"><input type="checkbox" id="uadmin"> Admin</label>
|
||||
<span class="hint">At least 8 characters. Leave the password empty for someone who signs in
|
||||
through the proxy. New people start with no feeds.</span></div>
|
||||
<div class="cardacts"><button class="btn" onclick="closeModal()">Close</button>
|
||||
<button class="btn primary" id="uadd">Add</button></div>`);
|
||||
const change=async(u,opts)=>{
|
||||
try{
|
||||
await api(`/api/users/${u.id}`,opts);
|
||||
// Demoting yourself takes this screen away; reload so the page stops offering it. Only
|
||||
// on success: reloading after a refusal wiped the toast that said why.
|
||||
if(u.name===S.me?.name){ location.reload(); return; }
|
||||
}catch(e){ toast(e.message,true); }
|
||||
usersModal(); // on a refusal, this puts the checkbox back where the server left it
|
||||
};
|
||||
$$('#modalCard [data-id]').forEach(row=>{
|
||||
const u=users.find(x=>String(x.id)===row.dataset.id);
|
||||
$('[data-a="admin"]',row).onchange=e=>
|
||||
change(u,{method:'PATCH',body:JSON.stringify({admin:e.target.checked})});
|
||||
$('[data-a="rm"]',row).onclick=()=>{
|
||||
if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`))
|
||||
change(u,{method:'DELETE'});
|
||||
};
|
||||
});
|
||||
$('#uadd').onclick=async()=>{
|
||||
try{
|
||||
await api('/api/users',{method:'POST',body:JSON.stringify({
|
||||
name:$('#uname').value, password:$('#upass').value, admin:$('#uadmin').checked})});
|
||||
toast('Added'); usersModal();
|
||||
}catch(e){ toast(e.message,true); } // keep what was typed
|
||||
};
|
||||
}
|
||||
|
||||
function settingsModal(f){
|
||||
const isGroup = S.feeds.some(c=>c.group===f.id);
|
||||
openModal(`<h3>${esc(f.title||f.id)}</h3>
|
||||
@@ -1360,8 +1414,9 @@ on('#signout','onclick',async()=>{ await api('/api/logout',{method:'POST'}); loc
|
||||
api('/api/me').then(u=>{
|
||||
S.me=u;
|
||||
$('#who').textContent=u.name+(u.admin?' · admin':'');
|
||||
// Scanning, quotas and the download folder are the operator's business.
|
||||
if(!u.admin) $('#prefs').hidden=true;
|
||||
// Scanning, quotas, accounts and the log are the operator's business. The server refuses
|
||||
// them too; hiding the buttons just stops offering what would fail.
|
||||
if(!u.admin){ $('#prefs').hidden=true; $('#logs').hidden=true; }
|
||||
}).catch(()=>{});
|
||||
on('#logs','onclick',logsModal);
|
||||
$('#feedFilter').oninput=renderFeeds;
|
||||
|
||||
Reference in New Issue
Block a user