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:
2026-09-11 12:43:28 +00:00
parent 6114add4a6
commit 5e95557cbb
11 changed files with 370 additions and 40 deletions

View File

@@ -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. 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 ## 2026-09-11 — Documentation
`PROGRESS.md` became this changelog; the finished step lists moved to an appendix. The README is now `PROGRESS.md` became this changelog; the finished step lists moved to an appendix. The README is now

View File

@@ -6,31 +6,37 @@ made here.
## Where things are ## 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.
| | | | | Host | In the container |
|---|---| |---|---|---|
| Binary | `/config/.cargo/bin/ipx` | | Image | `192.168.1.130:5000/ipodderx:latest` | |
| Config | `/config/.config/ipx/config.toml` | | Config | `/mnt/fast/appdata/ipodderx/config.toml` | `/config/config.toml` |
| Database | `/config/.local/share/ipx/state.db` | | Database | `/mnt/user/ipodderx/state.db` | `/data/state.db` |
| Downloads | `/mnt/user/audio/ipx` | | Downloads | `/mnt/user/ipodderx/downloads` | `/downloads` |
| Web UI | `0.0.0.0:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | | 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 ```sh
cargo build --release docker buildx build --tag 192.168.1.130:5000/ipodderx:latest . --push
pkill -x ipx; sleep 2 docker compose -f /mnt/fast/arcane/projects/content/compose.yaml pull ipodderx
cp target/release/ipx /config/.cargo/bin/ipx docker compose -f /mnt/fast/arcane/projects/content/compose.yaml up -d ipodderx
setsid nohup /config/.cargo/bin/ipx --config /config/.config/ipx/config.toml daemon \ docker logs --tail 20 iPodderX
>/tmp/ipx.log 2>&1 </dev/null &
``` ```
**`pkill -x ipx`, never `pkill -f ipx`.** `-f` matches the shell running the command and kills the **Name the service.** A bare `up -d` recreates every container in `content`, beets and immich
session (exit 144). This has happened more than once. 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 The healthcheck runs `ipx status` against the control socket, so `(healthy)` in `docker ps` means
has installed. 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 ## Before you touch the page
@@ -110,10 +116,7 @@ Deliberate simplifications get a `ponytail:` comment naming the ceiling and the
## Known gaps ## 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` * Cloudflare's `Cf-Access-Jwt-Assertion` is not verified — ipx trusts the hop plus `trusted_proxies`
(documented in [docs/sso.md](docs/sso.md)). (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 * A feed's `<description>` subtitle is dropped whenever `content:encoded` exists, which loses
Substack-style subtitles. Substack-style subtitles.

View File

@@ -85,11 +85,15 @@ never left behind with nothing explaining where it came from.
## Docker ## Docker
```sh ```sh
docker compose up -d # builds the image and starts it docker buildx build --tag 192.168.1.130:5000/ipodderx:latest . --push
docker compose logs -f ipx # the token is printed on first start 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 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 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. socket, so it catches a daemon that is alive but wedged rather than merely one that has died.

View File

@@ -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/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning |
| `POST /api/fetch`, `GET /api/opml`, `POST /api/opml` | | | `POST /api/fetch`, `GET /api/opml`, `POST /api/opml` | |
| `GET /api/settings`, `PATCH /api/settings` | admin-only to write | | `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/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 | | `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 Show notes are feed-supplied HTML from an untrusted source, sanitized with `ammonia` server-side

View File

@@ -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 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. 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 ## Admin
The first account is an admin. An admin can change global settings (scanning interval, quota, 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 retention, media types, download folder), a feed's URL, folder and schedule, and who has an account
the Settings button hidden and a `403` if they ask anyway. 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 ```sh
ipx user list # the admin column says who ipx user list # the admin column says who

View File

@@ -74,6 +74,8 @@ pub fn router(state: WebState) -> Router {
.route("/api/fetch", post(fetch_now)) .route("/api/fetch", post(fetch_now))
.route("/api/opml", get(export_opml).post(import_opml)) .route("/api/opml", get(export_opml).post(import_opml))
.route("/api/settings", get(get_settings).patch(patch_settings)) .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/logs", get(logs))
.route("/api/events", get(events)) .route("/api/events", get(events))
.route("/media/{id}", get(media)) .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 })) 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> { async fn login_page() -> Html<&'static str> {
Html(include_str!("../web/login.html")) Html(include_str!("../web/login.html"))
} }
@@ -473,6 +594,14 @@ impl IntoResponse for ApiError {
mod tests { mod tests {
use super::*; 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] #[test]
fn token_comparison_rejects_mismatches_and_length_differences() { fn token_comparison_rejects_mismatches_and_length_differences() {
assert!(constant_time_eq("abc123", "abc123")); assert!(constant_time_eq("abc123", "abc123"));
@@ -1161,9 +1290,13 @@ struct LogPage {
latest: u64, 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)); 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. /// One line per HTTP request, so the web side shows up in the same log as the daemon.

View File

@@ -44,7 +44,9 @@ const ctx = {
json: () => Promise.resolve( json: () => Promise.resolve(
String(url).includes('/api/settings') String(url).includes('/api/settings')
? { schedule: 'every 60m', every_mins: 60, download_dir: '/tmp', max_total_gb: 0, max_age_days: 0 } ? { 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 = () => {}; }, EventSource: function () { this.close = () => {}; },
MediaMetadata: function () {}, MediaMetadata: function () {},
@@ -79,6 +81,7 @@ const drive = [
['downloadLatestModal', () => ctx.downloadLatestModal(feed)], ['downloadLatestModal', () => ctx.downloadLatestModal(feed)],
['removeFeed', () => ctx.removeFeed(feed)], ['removeFeed', () => ctx.removeFeed(feed)],
['prefsModal', () => ctx.prefsModal()], ['prefsModal', () => ctx.prefsModal()],
['usersModal', () => ctx.usersModal()],
['logsModal', () => ctx.logsModal()], ['logsModal', () => ctx.logsModal()],
// `const S` is not reachable from here: top-level const/let do not become properties // `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. // of a vm context the way var and function declarations do.

View File

@@ -10,7 +10,7 @@ test.beforeEach(async ({ page }) => {
test('the page loads and lists the configured feeds', 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 // 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. // 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.locator('.feed')).toHaveCount(4, { timeout: 15_000 });
await expect(page.getByText('Test Show')).toBeVisible(); await expect(page.getByText('Test Show')).toBeVisible();
const errors = []; const errors = [];
@@ -178,16 +178,50 @@ test('an OPML subscription is a collapsible folder', async ({ page }) => {
const chev = page.locator('.feed.group .chev'); const chev = page.locator('.feed.group .chev');
await expect(chev).toBeVisible({ timeout: 20_000 }); 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(); const before = await page.locator('.feed').count();
await chev.click(); 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. // Scoped to the sidebar: the name also appears as the page heading once selected.
await expect(page.locator('#feedlist').getByText('Grouped Show')).toBeVisible(); await expect(page.locator('#feedlist').getByText('Grouped Show')).toBeVisible();
// The subscription's own page lists what is inside it. // The subscription's own page lists what is inside it.
await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click(); 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 }) => { 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. // 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('#feedlist')).toContainText('No feeds.');
await expect(page.locator('#prefs')).toBeHidden(); // not an admin 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 // Subscribing to a feed the admin already has costs no second fetch: same feed, same
// files, but Sam's own read state. // 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 page.locator('.tabs button', { hasText: 'Downloaded' }).click();
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 }); 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);
});

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

View File

@@ -1,4 +1,5 @@
<opml version="2.0"><head><title>Test Subscriptions</title></head> <opml version="2.0"><head><title>Test Subscriptions</title></head>
<body><outline text="Folder"> <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 type="rss" text="Grouped Show" xmlUrl="http://127.0.0.1:8792/other.xml"/>
</outline></body></opml> </outline></body></opml>

View File

@@ -513,6 +513,9 @@ async function loadFeeds(keepSel){
renderFeeds(); renderFeeds();
if(!keepSel && !S.feed && S.feeds.length) selectFeed(S.feeds[0].id); 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(){ function renderFeeds(){
const q=$('#feedFilter').value.trim().toLowerCase(); const q=$('#feedFilter').value.trim().toLowerCase();
const list=$('#feedlist'); const top=list.scrollTop; list.innerHTML=''; const list=$('#feedlist'); const top=list.scrollTop; list.innerHTML='';
@@ -527,7 +530,8 @@ function renderFeeds(){
order.push([f,0]); order.push([f,0]);
// A subscription can hold dozens of feeds, so a folder starts closed. Searching // 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. // 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){ for(const [f,depth] of order){
const kids=shown.filter(c=>c.group===f.id).length; const kids=shown.filter(c=>c.group===f.id).length;
@@ -646,7 +650,7 @@ function renderGroup(f,kids){
const draw=()=>{ const draw=()=>{
const q=($('#kidSearch').value||'').trim().toLowerCase(); const q=($('#kidSearch').value||'').trim().toLowerCase();
const box=$('#kidlist'); box.innerHTML=''; 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; } if(!rows.length){ box.innerHTML='<p class="empty">Nothing matches.</p>'; return; }
for(const c of rows){ for(const c of rows){
const el=document.createElement('div'); const el=document.createElement('div');
@@ -1227,9 +1231,13 @@ async function prefsModal(){
</div> </div>
<span class="hint">Export hands every subscription to another podcast app. Import adds the <span class="hint">Export hands every subscription to another podcast app. Import adds the
feeds listed in an OPML you paste in.</span></div> 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> <div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button>
<button class="btn primary" id="gsave">Save</button></div>`); <button class="btn primary" id="gsave">Save</button></div>`);
$('#gopml').onclick=opmlModal; $('#gopml').onclick=opmlModal;
$('#gusers').onclick=usersModal;
$('#gsave').onclick=async()=>{ $('#gsave').onclick=async()=>{
try{ try{
await api('/api/settings',{method:'PATCH',body:JSON.stringify({ 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){ function settingsModal(f){
const isGroup = S.feeds.some(c=>c.group===f.id); const isGroup = S.feeds.some(c=>c.group===f.id);
openModal(`<h3>${esc(f.title||f.id)}</h3> 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=>{ api('/api/me').then(u=>{
S.me=u; S.me=u;
$('#who').textContent=u.name+(u.admin?' · admin':''); $('#who').textContent=u.name+(u.admin?' · admin':'');
// Scanning, quotas and the download folder are the operator's business. // Scanning, quotas, accounts and the log are the operator's business. The server refuses
if(!u.admin) $('#prefs').hidden=true; // them too; hiding the buttons just stops offering what would fail.
if(!u.admin){ $('#prefs').hidden=true; $('#logs').hidden=true; }
}).catch(()=>{}); }).catch(()=>{});
on('#logs','onclick',logsModal); on('#logs','onclick',logsModal);
$('#feedFilter').oninput=renderFeeds; $('#feedFilter').oninput=renderFeeds;