From 1a9fb9a42731c68a558ade23f9298d2e806d8ae1 Mon Sep 17 00:00:00 2001 From: KodeStar Date: Tue, 13 Oct 2026 12:14:53 +0100 Subject: [PATCH 01/11] Household members (player 8b-1): the Household page, members, kids and the admin side User Guide: a new Your household page (where it is, adding a kid and a grown-up, what they can listen to, a member's card and their devices, deleting versus removing from this device, the lost-phone tick); profiles, kids mode (the quick way, a kid's own device, the collections shelf), settings, You, browsing, account, in the house and in the car follow the Household page; the admin guide gains household members (the two settings, the per-person switch, Assign to someone, Make independent, deleting an owner) and the console's Household settings topic and audit area; the invite-only claims now name household members. Developer Docs: the eight member routes, also_members and keep_members, the new user and admin fields, the capability, the kid 403s and every new error code in the reference and conventions; household.* in configuration; 0044 in the data model; the member rule in auth and security; the audit area; the Household page, addMemberHere, kids by default, the kids shelf and the car in the frontend pages; seam 16 of the cross-repo contract (profiles and kids mode are client-side, household members server-side); the docs mapping. --- .../architecture/cross-repo-contract.md | 53 ++- docs-developers/contributing/documentation.md | 5 +- .../frontend/connect-and-addresses.md | 9 + docs-developers/frontend/kids-mode.md | 70 +++- .../frontend/listening-in-the-house.md | 17 +- .../frontend/native-integrations.md | 8 + docs-developers/frontend/overview.md | 6 +- docs-developers/frontend/profiles.md | 103 ++++- docs-developers/frontend/state-and-data.md | 41 +- docs-developers/frontend/you-and-settings.md | 42 +- docs-developers/server/api/index.md | 30 +- docs-developers/server/api/reference.md | 362 ++++++++++++++++-- docs-developers/server/auth-and-security.md | 98 ++++- .../server/backups-and-notifications.md | 5 +- docs-developers/server/configuration.md | 18 + docs-developers/server/data-model.md | 27 +- docs-users/admin/console-tour.md | 2 +- docs-users/admin/server.md | 33 +- docs-users/admin/users-and-invites.md | 97 ++++- docs-users/getting-started/remote-access.md | 2 +- docs-users/index.md | 2 +- docs-users/listening/account.md | 2 + docs-users/listening/browsing.md | 2 +- docs-users/listening/connecting.md | 2 +- docs-users/listening/household.md | 105 +++++ docs-users/listening/in-the-car.md | 4 + docs-users/listening/in-the-house.md | 5 +- docs-users/listening/kids-mode.md | 44 ++- docs-users/listening/profiles.md | 30 +- docs-users/listening/settings.md | 18 +- docs-users/listening/you.md | 8 +- sidebars-users.ts | 1 + 32 files changed, 1103 insertions(+), 148 deletions(-) create mode 100644 docs-users/listening/household.md diff --git a/docs-developers/architecture/cross-repo-contract.md b/docs-developers/architecture/cross-repo-contract.md index 3b7811f..4b2307c 100644 --- a/docs-developers/architecture/cross-repo-contract.md +++ b/docs-developers/architecture/cross-repo-contract.md @@ -271,7 +271,8 @@ version. **Server:** `handleServerInfo` advertises `admin_ui`, `web_player`, `upload`, `transcode`, `websocket`, `api_keys`, `export`, `metadata`, `meta_bundle`, `browse_people`, `cover_sizes`, `next_book` and the flags added after it (the -latest, `household` and `cast`, gate [§15](#15-listening-in-the-house-and-casting)), plus the +latest: `household` and `cast`, which gate [§15](#15-listening-in-the-house-and-casting), +and `household_members`, which gates [§16](#16-household-members-profiles-and-kids-mode-stay-client-side)), plus the server version (`api.Version`, stamped from the release tag via ldflags). `transcode` reflects ffmpeg availability; `web_player` reflects whether `/web` is populated; `api_keys` @@ -554,6 +555,56 @@ address the receiver's URLs use: [Chromecast](../frontend/chromecast.md). must never filter someone in); any widening of where a cast token is accepted is a security-critical change with allowed and denied tests on the server. +## 16. Household members (profiles and kids mode stay client-side) + +**Profiles and kids mode are client-side; household members are server-side.** A +profile (a person on this device), its PIN and its kids mode never reach a server +([Profiles](../frontend/profiles.md)). A **household member** (player redesign Phase +8b-1, capability `household_members`) is a real account an owner creates from the +player, and everything about what it may do is the server's. + +**The wire** (all additive; shapes in +[the reference](../server/api/reference.md#household-members)): + +- `/me/household/members` (list, create) and `/{id}` (`PATCH`, `DELETE`), + `/{id}/session`, `/{id}/pair`, `/{id}/devices` and `/{id}/devices/{device_id}`; + mirrored in `types.ts` (`HouseholdMember`, `HouseholdMembers`, `AddMemberBody`, + `AddMemberResult`, `UpdateMemberBody`, `MemberKind`, `MemberAccess`), `client.ts` + and `hooks.ts` ([State and data](../frontend/state-and-data.md#household-members-player-redesign-phase-8b-1)). +- The `user` object (`/me` and every session envelope) gains `managed_by` (`{ id, + username }` or `null`) and `member_kind` (`"kid"`, `"grownup"` or `null`). The + member's access is not on it: the player can't see it and never needs it. +- `/me/devices` rows gain `member_sign_ins`, and `DELETE /me/devices/{id}?also_members=1` + answers `{ current, members_signed_out }`. +- The admin's user objects gain `member_access`, `can_add_members`, `members_count` + and `display_name`; `PATCH /admin/users/{id}` takes `can_add_members` and + `managed_by`; `DELETE /admin/users/{id}?keep_members=`. The settings envelope gains + `household` (`members_enabled`, `max_members`). These are the console's, inside the + server repo. + +**Server rules** (each with an allowed and a denied test; +[Auth & security](../server/auth-and-security.md#household-members)): the member rule +in `UserScope`/`UserScopes`/`AccessibleLibraries` (the owner's current access for +`all`, the owner's shared collections intersected with it for `collections`, plus +the member's own grants), one level and never admin, the kid refusals +(`403 kid_account`), kids out of share targets and Listening in the house, someone +else's member always `404`. + +**Client rules:** a member signed in on the owner's device is placed by +`addMemberHere` (the owner stays the person listening, the default connection +doesn't move); `member_kind === 'kid'` turns kids mode on by default and allows it +with no grown-up on the device; `managed_by` picks the kids shelf's collections. The +player never filters a kid's access itself. + +**Old clients:** a shipped player ignores the new `user` fields and sees a member as +an ordinary account; a kid signed in to an old player is still limited by the +server. An older server has no flag, so the player offers no "Add someone to your +household" and everything Phase 8 shipped works as before. + +**A change requires:** any change to the member rule is security-critical (allowed and +denied tests on the server); a new member field is the usual wire change in both +repos, gated on the flag. + ## The wire-change checklist Every change to the wire format follows the same shape (the worked example in diff --git a/docs-developers/contributing/documentation.md b/docs-developers/contributing/documentation.md index efa9da3..9048062 100644 --- a/docs-developers/contributing/documentation.md +++ b/docs-developers/contributing/documentation.md @@ -71,8 +71,9 @@ mappings: | Smart speed, Voice boost, time saved (`effects.ts`, `time-saved.ts`, `effects-settings.tsx`, the Android `effects/` processors, `VoiceBoostTap.swift`, `VoiceBoostDSP.swift`) | [frontend/audio-effects.md](../frontend/audio-effects.md) (+ [frontend/book-page.md](../frontend/book-page.md) for `smartSpeedSaved`) + `/users/listening/full-player` (Smart speed and Voice boost), the Playback settings in `/users/listening/settings`, the Your listening row in `/users/listening/book-page` + the `web-player/phone-speed-sheet.png`, `settings.png` and `phone-settings.png` screenshots | | CarPlay, Android Auto (`src/car/`, the module's car code, `plugins/withCarPlay.js`), the after-first-unlock token store, the iOS widgets and Live Activity (`src/widgets/`) | [frontend/native-integrations.md](../frontend/native-integrations.md) (+ [testing.md](../frontend/testing.md#native-checks) for how to run them) + `/users/listening/in-the-car`, `/users/listening/mobile-apps` (Availability, In the car, widgets), `/users/listening/sleep-timer` (the Live Activity). The web can't show these surfaces: device screenshots, when there are any, are added by hand to the manifest | | Connection ids, the identity migration (`src/lib/connection-id.ts`, `src/lib/identity-migration.ts`, a new document keyed by a connection id) | [frontend/state-and-data.md](../frontend/state-and-data.md#connection-identity-server_iduser_id) (+ `ID_DOCUMENTS` when a new document is keyed by a connection id) | -| Profiles, the PIN, Settings > Household (`src/stores/profiles.ts`, `src/lib/profile-pin.ts`, `src/components/profiles/`) | [frontend/profiles.md](../frontend/profiles.md) + `/users/listening/profiles`, the Household and sharing section of `/users/listening/settings` | -| Kids mode (`src/components/kids/`, the bedtime rule in `auto-sleep-controller.ts`) | [frontend/kids-mode.md](../frontend/kids-mode.md) + `/users/listening/kids-mode` | +| Profiles, the PIN, the Household page's On this device (`src/stores/profiles.ts`, `src/lib/profile-pin.ts`, `src/components/profiles/`) | [frontend/profiles.md](../frontend/profiles.md) + `/users/listening/profiles`, `/users/listening/household` | +| Household members: the Household page, the add sheet, member cards, `addMemberHere` (`src/components/household/`, `src/components/connect/add-member-here.ts`; server `auth/members.go`, `catalog/members.go`, `handlers_members.go`, the `household.*` settings; the console's `people/household.tsx`) | [frontend/profiles.md](../frontend/profiles.md#the-household-page) + the [reference](../server/api/reference.md#household-members) + [auth-and-security.md](../server/auth-and-security.md#household-members) + [server/configuration.md](../server/configuration.md#household-members-household) + the [cross-repo contract](../architecture/cross-repo-contract.md#16-household-members-profiles-and-kids-mode-stay-client-side) + `/users/listening/household`, `/users/listening/kids-mode`, `/users/listening/account` (the lost-phone tick), `/users/admin/users-and-invites#household-members`, `/users/admin/server` (Household) | +| Kids mode (`src/components/kids/`, the bedtime rule in `auto-sleep-controller.ts`, the kids car snapshot in `src/car/`) | [frontend/kids-mode.md](../frontend/kids-mode.md) + `/users/listening/kids-mode` (+ `/users/listening/in-the-car` for the car) | | Listening in the house (`src/components/house/`, the household hooks; server `catalog/household.go`) | [frontend/listening-in-the-house.md](../frontend/listening-in-the-house.md) + the [reference](../server/api/reference.md#listening-in-the-house) + [auth-and-security.md](../server/auth-and-security.md#listening-in-the-house-what-other-people-see) + `/users/listening/in-the-house`, `/users/admin/activity` (what the server records) | | Chromecast (`src/cast/`, `plugins/withCast.js`; server `auth/cast.go`, `handlers_cast.go`, the web CSP) | [frontend/chromecast.md](../frontend/chromecast.md) + the [reference](../server/api/reference.md#casting) + [auth-and-security.md](../server/auth-and-security.md#cast-tokens) + [server/web-ui.md](../server/web-ui.md#per-document-csp-htmlcsp) for the web CSP (`castSenderScripts`, the policy `TestHTMLCSPCastSender` pins) + `/users/listening/chromecast`, `/users/listening/playback` (other speakers), `/users/getting-started/remote-access` (the certificate note) | | Downloads / PWA, the offline companion (`offline-meta.ts`) | [frontend/offline.md](../frontend/offline.md) + `/users/listening/offline-downloads` | diff --git a/docs-developers/frontend/connect-and-addresses.md b/docs-developers/frontend/connect-and-addresses.md index 607bad3..82dd46c 100644 --- a/docs-developers/frontend/connect-and-addresses.md +++ b/docs-developers/frontend/connect-and-addresses.md @@ -63,6 +63,15 @@ addresses the link (`linkAddresses`) and the answer taught, merged there once connection then shows `/connect/ready`; an added server goes straight back with `leaveOnboarding()`. +A kid's account (`user.member_kind === 'kid'`, a household member) placed for the +first time on this device starts in kids mode (`kidsOnForKidAccount`, after the +placement; never on a re-pair, and never for a profile that also holds a grown-up's +account): see [Kids mode](kids-mode.md). The one sign-in that does **not** go through +`finishConnect` is a household member signed in beside the owner from the Household +page: `addMemberHere` (`src/components/connect/add-member-here.ts`) stores it without +moving the default connection or the person listening, and never navigates (see +[Profiles](profiles.md#signing-a-member-in-here-addmemberhere)). + `repairPlan` decides what a sign-in stores: - the **same connection again** (its `server_id` answered through one of its own diff --git a/docs-developers/frontend/kids-mode.md b/docs-developers/frontend/kids-mode.md index 471d978..6356936 100644 --- a/docs-developers/frontend/kids-mode.md +++ b/docs-developers/frontend/kids-mode.md @@ -1,14 +1,18 @@ --- title: Kids mode -description: "Kids mode (Phase 8): a property of a profile. The /kids route and its guard, which library and stories it shows, the kids player, the Grown-ups hold, and bedtime through the auto sleep controller." +description: "Kids mode (Phase 8, with kid household members since 8b-1): a property of a profile. Kids mode by default for a kid's account, the /kids route and its guard, the stories it shows (the owner's shared collections, else one library), the kids player, the Grown-ups hold, the car, and bedtime through the auto sleep controller." --- Kids mode is a **property of a [profile](profiles.md)** (`Profile.kids`), set by an -adult on the device in Settings > Household and sharing. The child's account is an -ordinary server user whose library access the admin limited: the server's scopes -(`GET /libraries`) are the truth, and the player never shows a child more than the -account may open. There is no server change. The user-facing page is -[Kids mode](/users/listening/kids-mode). +adult on the device on the [Household page](profiles.md#the-household-page). It is +client-side: the server has no notion of it. The child's account is either a **kid +household member** (Phase 8b-1: `user.member_kind === 'kid'`, made by an owner from +the player, its access decided by the server's member rule; see +[Household members](/developers/server/api/reference#household-members)) or an ordinary +account whose library access the admin limited (Phase 8). Either way the server's +scopes are the truth, and the player never shows a child more than the account may +open; turning kids mode off on a device never widens anyone's access. The user-facing +page is [Kids mode](/users/listening/kids-mode). ```ts kids?: { library?: { connectionId: string; libraryId: number }; bedtime: string /* 'HH:MM', default '19:30' */ } @@ -16,8 +20,20 @@ kids?: { library?: { connectionId: string; libraryId: number }; bedtime: string `setProfileKids(profileId, kids | undefined)` turns it on (an invalid bedtime reads as `DEFAULT_BEDTIME`) or off. Turning it on is refused when it would leave no grown-up on -the device (`mayTurnOnKids`, which the Household pane also reads to disable the -switch): leaving kids mode is a grown-up's job. `KidsLibraryPicker` offers the libraries the profile's +the device (`mayTurnOnKids`, which the Household page also reads to disable the +switch): leaving kids mode is a grown-up's job. **The one exception** is a profile +whose every connection the server says is a kid's (`isKidsAccount`: each connection's +`user.member_kind === 'kid'`): it may be in kids mode with no grown-up on the device +(a kid's own phone, paired from the owner's QR), since the server already limits that +account and there is no grown-up to leave kids mode for. + +**On by default for a kid's account.** `addMemberHere` turns kids mode on for a kid it +signs in beside the owner, and `finishConnect` does the same at a kid account's +**first** placement on a device (`kidsOnForKidAccount`: a new connection, not a +re-pair or a sign-in after a server reset, whose profile has no kids settings yet and +holds only kid accounts), which covers the kid's own device signing in from its QR. A +grown-up's profile that a kid's sign-in joined is never put in kids mode. On a server +without household members, `member_kind` is absent and nothing changes. `KidsLibraryPicker` offers the libraries the profile's connections list, named with their server when the profile has several; no pick means the first one listed. @@ -59,11 +75,26 @@ The rules are pure in `kids-model.ts`, read through `useKidsStories()`: - **The connection** (`kidsConnectionId`): the picked library's connection while the profile still holds it, else the profile's first. -- **The library** (`kidsLibrary`): the pick when it is on that connection and still - listed by `GET /libraries`, else the first library listed, else none (the account - sees no library: an empty state says to ask a grown-up). -- **The shelf** (`kidsShelf`): the books in progress, most recently played first, then - every other book of the library in the server's order, finished ones included. +- **The collections** (`kidsCollections`, Phase 8b-1): for an account with + `user.managed_by` (a household member), the collections `GET /me/collections` lists + as shared with it (`!owned`) whose `owner.id` is `managed_by.id`, in the order the + list gives them (the owner's). When there is at least one, they **are** the shelf: + `kidsCollectionSections` reads each one's `GET /me/collections/{id}` and gives one + section per collection with stories (titled with the collection's name when there + are several, untitled when one), each in `kidsOrder` (stories in progress first, + most recently played first, then the rest in the collection's order, a book listed + twice shown once). Collections with no story yet are left out; with none left the + shelf says "Ask a grown-up to add stories." The client can't see `member_access` (it + isn't on `/me`), so an `all` member whose owner also shares collections with them + sees those collections; one with none sees the one-library view. +- **The library** (`kidsLibrary`), for everyone else (an account nobody manages, a + Phase 8 account the admin limited, an older server, a member with no shared + collection): the pick when it is on that connection and still listed by `GET + /libraries`, else the first library listed, else none (the account sees no + library: an empty state says to ask a grown-up). +- **The shelf** (`kidsShelf`) from one library: the books in progress, most recently + played first, then every other book of the library in the server's order, finished + ones included. - **Offline** (`kidsDownloads`): when the server can't be reached, the finished downloads of that connection, newest first, in the library `kidsOfflineLibraryId` names: the one shown when known, else (`GET /libraries` unreadable) the profile's pick @@ -92,6 +123,17 @@ reduced motion there is no fill, and it still takes the full 2 s (the timer deci not the animation). A screen reader gets the hint "Hold to leave kids mode" and the long-press action. Holding opens Who's listening?, where the chosen profile's PIN is asked. +## In the car + +A kids profile's CarPlay and Android Auto snapshot is the kids shelf only +(`kidsCarBooks` in `src/car/car-model.ts`, built by `car-controller.ts` without React +from the same reads as `useKidsStories`: the owner's shared collections, else the one +library, else the account's downloads): the **Library** tab holds the shelf, titled +"Your stories", and **Continue listening** the shelf's stories in progress; no Up next +and no Downloads tab, nothing else the account could reach. It is rebuilt when kids +mode turns on or off. See +[Native integrations](native-integrations.md#the-car-snapshot-srccar). + ## Bedtime (`src/playback/auto-sleep-controller.ts`) Bedtime reuses the auto sleep controller, made profile-aware: @@ -116,7 +158,7 @@ running timer has left. ## Testing -`kids-model.test.ts` (the rules above), `use-kids.test.tsx`, `kids-screen.test.tsx`, +`kids-model.test.ts` (the rules above, the collections shelf included), `use-kids.test.tsx`, `kids-screen.test.tsx`, `kids-player.test.tsx`, `grown-ups-button.test.tsx`, `kids-route-guard.test.tsx` and `kids-gate.test.tsx` in `src/components/kids/`; the bedtime rule's regression tests in `src/playback/auto-sleep-controller.test.ts`. That a tapped story starts in place and diff --git a/docs-developers/frontend/listening-in-the-house.md b/docs-developers/frontend/listening-in-the-house.md index 26f984c..c3aa0da 100644 --- a/docs-developers/frontend/listening-in-the-house.md +++ b/docs-developers/frontend/listening-in-the-house.md @@ -86,8 +86,8 @@ Strings live in the `house` i18n namespace. `HomeSection` per connection whose server has `household`, where the listener shares and someone else who shares listened in the last day; it renders nothing otherwise. The section's sub is "`` · shared by choice" and its action - "Sharing settings" opens Settings at the household pane - (`openSettings('household')`). Each `HouseCard` is the person's avatar and name (a + "Sharing settings" opens the Household page + (`openHousehold()`). Each `HouseCard` is the person's avatar and name (a static green dot after the name while they are listening now), the book's title, the state line and its cover; tapping it opens the book on that connection (`openBook`). - **`InTheHouseCard({ connectionId, libraryId, path })`** - the book page's "In the @@ -98,7 +98,7 @@ Strings live in the `house` i18n namespace. the house" for one connection, with the privacy sentence "Your current book shows on the Home page of other people on `` who share theirs too. Off by default." Hidden when the connection is gone; it does not check `household` itself (the - Household pane renders it only for servers that advertise the flag, below). The switch + Household page renders it only for servers that advertise the flag, below). The switch moves at once (optimistic) and settles on the server's answer; a refusal puts it back with a toast (a demo account gets its own words); while it loads or saves the switch waits disabled, and a failed load offers Retry. @@ -111,10 +111,13 @@ Strings live in the `house` i18n namespace. the layout. Leave `ring` out where no ring ever shows. **Whose connections.** Everything here is the person listening's: Home hands -`HouseRow` the active profile's connections (`useProfileConnections`), and -Settings > Household and sharing renders one `HouseSharingRow` per connection of the -active profile whose server has `household`, under a "Listening in the house" heading -(a note when none of them offers it, once every server has answered). See +`HouseRow` the active profile's connections (`useProfileConnections`), and the +[Household page](profiles.md#the-household-page) (`HouseSharing`; Settings' Household +pane before Phase 8b-1) renders one `HouseSharingRow` per connection of the active +profile whose server has `household`, under a "Listening in the house" heading (a note +when none of them offers it, once every server has answered). A kid household member never +shares and never sees the house: the server answers `{ sharing: false, listening: [] +}` and refuses its `PUT` (`403 kid_account`). See [Profiles](profiles.md#visible-and-background-connections). ## Testing diff --git a/docs-developers/frontend/native-integrations.md b/docs-developers/frontend/native-integrations.md index 7f0e1ea..f551a8f 100644 --- a/docs-developers/frontend/native-integrations.md +++ b/docs-developers/frontend/native-integrations.md @@ -67,6 +67,14 @@ protection), so a car shows its lists at once, before or without JS. [State and data](state-and-data.md#library-selection-srcstoreslibrary-selectionts)), `sort: 'recent'`, at most 50 (`CAR_TAB_LIMITS`; Android Auto doesn't page, and CarPlay trims further to its own `maximumItemCount`). +- **A kids profile** (player redesign 8b-1) gets the kids shelf only + (`kidsCarBooks`): the `library` tab holds the shelf as [kids mode](kids-mode.md#in-the-car) + shows it (the owner's shared collections, else the one library, else the account's + downloads), titled "Your stories" through the snapshot input's `titles`, and + `continue` the shelf's stories in progress; `upnext` and `downloads` are `null`, so + those tabs are left out (`CarSnapshotInput.books` is `Record`, a `null` tab omitted). The controller builds it without React from the same + reads as `useKidsStories` and rebuilds the snapshot when kids mode turns on or off. - Each `CarItem` has an `id` (`carItemId(ref)`: `book:` plus the URI-encoded connection id, library id and path joined by `:`; path is the identity, scoped by connection, never a database id; `parseCarItemId` reverses it, mapping a connection id from before the diff --git a/docs-developers/frontend/overview.md b/docs-developers/frontend/overview.md index 6536196..1cc6d4a 100644 --- a/docs-developers/frontend/overview.md +++ b/docs-developers/frontend/overview.md @@ -61,7 +61,8 @@ src/components/ ui/ (the Stacks primitives - Text, Icon, Button, Card, Input settings/ (the Settings panes), account/ (a server's Account page), connect/ (onboarding), home/, search/, upnext/, downloads/ (the Downloads tab), house/ (Listening in the house), profiles/ - (Who's listening?, the PIN dialog, Settings > Household), kids/ + (Who's listening?, the PIN dialog, On this device), household/ + (the Household page: members, the add sheet, member cards), kids/ (kids mode), brand/ src/car/ CarPlay + Android Auto, the JS side: the car snapshot (car-model), car covers, the car controller, the AudiosiloCar headless task @@ -145,8 +146,9 @@ URLs, so every URL below is the same as before the groups existed. | `/library/[libraryId]?connection=…&path=…` | `(app)/(home,library,search,offline,me)/library/[libraryId].tsx` | Library browse, root and nested folders alike - a two-line re-export of `src/components/library/browse-screen.tsx`. Content routes are **flat**: the connection id and the library-relative folder `path` ride as query params, never as nested route segments (an in-app `router.push` cannot resolve a route nested under a dynamic layout segment - it lands on the group's first child; rationale and helpers in `src/lib/paths.ts`). Each content screen scopes itself to its own `?connection=` with ``, and the content hooks read that scope via `useScopedCid()`. | | `/book/[libraryId]?connection=…&path=…&tab=…` | `(app)/(home,library,search,offline,me)/book/[libraryId].tsx` → `src/components/book/book-page.tsx` | The book page, opened on `tab` when that tab exists (`parseBookTab`): see [The book page](book-page.md). | | `/downloads` | `(app)/(offline)/downloads.tsx` → `src/components/downloads/downloads-screen.tsx` | The Downloads page: storage per server, the automatic-download rules (including keep-ahead), in-progress and failed downloads, and the books ready offline by server ([Offline](offline.md#the-downloads-page)). The group is `(offline)`, not `(downloads)`, on purpose - see [cold deep links](#the-shell-tabs-and-navigation). | -| `/you?section=stats\|year\|journal\|settings\|account` | `(app)/(me)/you.tsx` → `src/components/you/you-hub.tsx` | The root of the **Me** tab, the **You** hub: Your listening, Year in listening, the Journal, Settings and Account on a phone (a scrolling segmented control); Stats, Year in listening and Journal in the sub-nav on tablet/desktop (Settings is the gear, Account the profile menu). `section` and the Journal's `tab` are its `rootParams`. See [You, Settings and Account](you-and-settings.md#the-hub-and-its-routes). | +| `/you?section=stats\|year\|journal\|settings\|household\|account` | `(app)/(me)/you.tsx` → `src/components/you/you-hub.tsx` | The root of the **Me** tab, the **You** hub: Your listening, Year in listening, the Journal, Settings, Household and Account on a phone (a scrolling segmented control); Stats, Year in listening and Journal in the sub-nav on tablet/desktop (Settings is the gear, Household and Account the profile menu). `section` and the Journal's `tab` are its `rootParams`. See [You, Settings and Account](you-and-settings.md#the-hub-and-its-routes). | | `/settings?section=…` | `(app)/(home,library,search,offline,me)/settings.tsx` → `src/components/settings/settings-content.tsx` | Every app setting, each in one pane (Listening, App, Servers groups), pushed on the current tab by the gear, the profile menu and the palette (`openSettings`). The phone hub's Settings section renders the same `SettingsContent`. See [Settings](you-and-settings.md#settings-srccomponentssettings). | +| `/household` | `(app)/(home,library,search,offline,me)/household.tsx` → `src/components/household/household-page.tsx` | The Household page (`HouseholdPage`, Phase 8b-1): the people on this device, the household members the person listening manages, and Listening in the house. Pushed on the current tab by `openHousehold` (the profile menu, Who's listening?, Settings, Home's house row, the palette); `/settings?section=household` redirects here; the phone hub's Household segment renders the same page. See [Profiles](profiles.md#the-household-page). | | `/journal?tab=diary\|bookmarks\|notes` | `(app)/(home,library,search,offline,me)/journal.tsx` → `src/components/journal/journal-screen.tsx` | The Journal, on every server of the person listening, for links made before the hub; entry points open the hub's Journal section (`openJournal`). See [The Journal](journal.md). | | `/account?connection=…` | `(app)/(home,library,search,offline,me)/account.tsx` → `src/components/account/account-section.tsx` | One server's account (`AccountSection`): password, pairing another device, the At home and away card, signed-in devices, personal API keys and sign-out, each gated on its capability, with a breadcrumb back to Settings or the You section it was opened from (`accountParentKey`; from anywhere else the chrome's own Back). Reached from Settings' Accounts and devices pane and the profile menu; the phone hub's Account section renders the same body for the default server. See [Account](you-and-settings.md#account-srccomponentsaccount). | | `/player` | `src/app/player.tsx` | The full player ([Player UI](player-ui.md#the-full-player)), presented as a full-screen modal above the tabs on every form factor (opened from the mini player, the iOS accessory player, the docked player bar, or a phone's Listen button). Accepts `libraryId`/`path` (+ optional `position`/`track`) params and gates playback start on the chapters query settling. | diff --git a/docs-developers/frontend/profiles.md b/docs-developers/frontend/profiles.md index 4f55662..5aefbff 100644 --- a/docs-developers/frontend/profiles.md +++ b/docs-developers/frontend/profiles.md @@ -1,6 +1,6 @@ --- title: Profiles -description: "Profiles (Phase 8): a person on this device as a group of connections. The profiles store and document, the consistency rules, visible versus background connections, switching and the play rule, Who's listening?, the device-local PIN, links to another person's book, and Settings > Household." +description: "Profiles (Phase 8): a person on this device as a group of connections. The profiles store and document, the consistency rules, visible versus background connections, switching and the play rule, Who's listening?, the device-local PIN, links to another person's book, the Household page, and signing a household member in beside the owner (addMemberHere)." --- A **profile is a person on this device**: a device-local group of connections, at @@ -11,6 +11,14 @@ the server; the profile only says which of the device's connections belong to wh so the screens show one person's at a time. Nothing about profiles is sent to a server: there is no wire change. +**Profiles and kids mode are client-side; household members are server-side.** A +[household member](/developers/server/api/reference#household-members) (Phase 8b-1, +capability `household_members`) is a real account on the server that an owner made +from the player, with what it may listen to decided by the server. On the device it is +just another connection in a profile of its own, placed by `addMemberHere` +([below](#signing-a-member-in-here-addmemberhere)); the server's `user.member_kind` +only decides whether that profile starts in kids mode. + The user-facing side is [Sharing a device with your household](/users/listening/profiles) and [Kids mode](/users/listening/kids-mode); kids mode's code is @@ -95,6 +103,12 @@ into the active profile, unless that profile already has a connection to this se re-pair keeps the person it had, and "Add someone" signing in as a person already on the device switches to them. +`placeConnection(id, opts)` takes `PlaceOptions` (`placeInDoc`'s): `newProfile`, and +for a household member signed in beside the owner `keepActive` (the new profile is +added **without** becoming active: the person listening stays, or nobody when the +active person was only this connection) and `name` (the new profile's name, trimmed; +empty means the username). + ### Visible and background connections The rule that keeps one person's state from leaking into another's view without @@ -152,14 +166,16 @@ someone**. Choosing the active person goes back; choosing someone with a PIN ask it; anyone else is switched to. Strings are in the `profiles` i18n namespace. - **Ways in**: the profile menu (tablet and desktop top bar), the Me tab's "Listening - as" card on a phone, Settings > Household and sharing, and leaving kids mode. + as" card on a phone, the Household page, and leaving kids mode. +- **Manage household** at its foot opens the [Household page](#the-household-page) + (`openHousehold`), except while someone must be chosen and for a kids profile. - **At a cold launch** the auth gate opens it once per launch when there are two or more people and `askAtLaunch` is on. - **Forced** while someone must be chosen (`mustChoose`): the gate opens it, and it has no Close and ignores Android's back. The shell is never mounted for a moment as the wrong person: the gate also waits for the profiles to hydrate. - `useChooseProfile()` is the one way to choose someone (the picker, the profile menu, - the Household pane, the gate below); `openProfiles()` the one way in. + the Household page, the gate below); `openProfiles()` the one way in. ## The PIN (`src/lib/profile-pin.ts`) @@ -175,7 +191,7 @@ anyone with the device and a browser's developer tools can read a session token digest, **10,000** digests in all (`PIN_ITERATIONS`), compared in constant time. The profile mirrors `hasPin`, so screens know without reading the secure store. - **The secure store decides whenever it can be read** (`readPin` first, in - `useChooseProfile` and the Household pane's **Set a PIN**): the mirror is written best + `useChooseProfile` and the Household page's **Set a PIN**): the mirror is written best effort with the whole profiles document, which a failed write or a second web tab holding an older document can leave behind. A stored hash the mirror lost still asks (and repairs the mirror); a mirror whose hash is gone (an install restored without its @@ -220,14 +236,50 @@ screen: The gate only offers the switch; refusing the play itself is the playback store's job (`mayActFor`, [above](#switching-and-the-play-rule)). -## Settings > Household and sharing - -`HouseholdPane` (`src/components/profiles/household-pane.tsx`), the App group's -`household` pane: a card per person (switch, rename, PIN set/change/remove, kids mode -with `KidsLibraryPicker` and the bedtime, **Remove from this device**), **Who's -listening?** and **Add someone**, the **Ask who's listening when the app opens** -switch, and the per-server house sharing switches -([Listening in the house](listening-in-the-house.md)). +## The Household page + +Phase 8b-1 moved Settings' Household pane to a page of its own, `HouseholdPage` +(`src/components/household/household-page.tsx`), rendered in two places: the phone +You hub's `household` segment (between Settings and Account, `embedded`, since the +hub's large title already says Household) and the pushed page `/household` +(`src/app/(app)/(home,library,search,offline,me)/household.tsx`, in the array group +like `/settings`, so back returns where the listener was and a cold link opens it in +Home). Every way in goes through `openHousehold()` +(`src/components/household/open-household.ts`, `householdHref()` in `paths.ts`): the +profile menu, "Who's listening?" > Manage household, the link at the end of Settings' +App group, Home's house row and the web palette. `/settings?section=household` +(`isHouseholdSection`) **replaces** itself with `/household`, so an old link lands on +the page and back doesn't bounce into the redirect. + +The page is a plain column of three parts: + +- **`DevicePeople`** (`src/components/profiles/household-pane.tsx`, "On this + device"): a card per person (switch, rename, PIN set/change/remove, kids mode with + `KidsLibraryPicker` and the bedtime, **Remove from this device**), **Who's + listening?** and **Sign in someone who already has an account** (the connect flow's + Add someone, `ADD_SOMEONE_HREF`), and the **Ask who's listening when the app + opens** switch. +- **`HouseholdMembers`** (`src/components/household/members-section.tsx`): per + connection of the person listening whose server has `household_members`, the + members list (`useHouseholdMembers`), a `MemberCard` each and **Add someone to your + household** (`AddMemberDialog`), or the explanation for the list's `reason` (or + `at_cap`, from `members.length >= max`). The pure rules are `household-model.ts` + (the name rule, the default access per kind, the error mapping). +- **`HouseSharing`**: the per-server house sharing switches + ([Listening in the house](listening-in-the-house.md)). + +**Adding a member** (`AddMemberDialog`): a kid is `addMember({ …, sign_in_here: true +})`, then `addMemberHere(session, { name, kids: true })`, then the offer to set the +owner's PIN when their profile has none; a grown-up is `addMember` then +`memberPairing` and the QR, with "They'll also use this phone" (`memberSession` then +`addMemberHere(…, { kids: false })`). The access default follows the Kids switch +(kid: `collections`, grown-up: `all`) until the owner picks one. A `MemberCard` +carries what they can listen to (and its change dialog), the Kids switch (`PATCH +kind`, confirmed when it makes a kid), their devices (`useMemberDevices`, each with +sign out), "Set up their own device", "Sign in on this device" +(`memberSignedInHere` is asked first, so no session is minted that `addMemberHere` +would refuse), rename, and "Delete their account" (confirmed twice, the second by +typing the name), kept apart from **Remove from this device**. **Remove from this device** (`useRemovePerson`) signs the device out of each of the person's servers through the same sign-out as an Account page (their book stops with @@ -236,12 +288,37 @@ then drops their PIN and profile. It warns first where they have no password on server (they would need a new invite). Removing the person listening leaves nobody chosen. +## Signing a member in here (`addMemberHere`) + +`addMemberHere(session, { name, kids })` (`src/components/connect/add-member-here.ts`) +is the variant of `finishConnect` that signs a household member in **beside** the +owner, with no navigation: + +- the member's connection is stored with the owner's `serverUrl` and server name (it + is the owner's server, and the answer was read through the owner's client), and both + connections learn the addresses the answer carried; +- `setSession(..., { makeDefault: false })`: the default connection stays where it is + (it still becomes the default when there is none, or the current default is no + longer signed in); +- `placeConnection(id, { newProfile: true, keepActive: true, name })`: a profile of + its own named as the owner typed, and **never** `switchProfile`, so the owner stays + the person listening and their book plays on; +- for a kid, `setProfileKids` with the default bedtime. + +It refuses, changing nothing, with `AddMemberHereError` (`already_here`) when the +device already holds that user on that server (`memberSignedInHere`; signing them in +again would replace the connection, PIN and all), and with an `Error` while the person +listening is in kids mode with a grown-up on the device (`mayAddPerson`), when the +profiles can't be read, or when the device has no connection to that server. It +resolves to `{ connectionId, profileId }`. + ## Testing `src/stores/profiles.test.ts` (parsing, `reconcileProfiles`, placement, `mustChoose`, switching), `src/lib/profile-pin.test.ts`, `sha256.test.ts`, `random.test.ts`, and in `src/components/profiles/`: `profiles-model.test.ts`, `profiles-screen.test.tsx`, -`pin-dialog.test.tsx`, `household-pane.test.tsx`; the gate in +`pin-dialog.test.tsx`, `household-pane.test.tsx`; `add-member-here.test.ts` in +`src/components/connect/`; the gate in `src/components/shell/auth-gate.test.tsx` and the menu in `profile-menu.test.tsx`; the "no screen lists another person's servers" checks beside each screen that reads connections. diff --git a/docs-developers/frontend/state-and-data.md b/docs-developers/frontend/state-and-data.md index d0838d5..76d7813 100644 --- a/docs-developers/frontend/state-and-data.md +++ b/docs-developers/frontend/state-and-data.md @@ -348,6 +348,43 @@ connection-scoped like the rest (`qk.queue`, `qk.collections`, `qk.collection`, - **Streaks are computed on the device** from `useMyListening`'s `days` (server time). +### Household members (player redesign Phase 8b-1) + +Capability `household_members`; the wire is +[Household members](/developers/server/api/reference#household-members). Types in +`types.ts`: `MemberKind`, `MemberAccess`, `HouseholdMember` (and its +`HouseholdMemberCollection`), `HouseholdMembers` (`{ allowed, reason?, max, members +}`, `HouseholdMembersReason`), `AddMemberBody`, `AddMemberResult` (`{ member, +session? }`), `UpdateMemberBody`; `User` gains the optional `managed_by` (a `UserRef`) and +`member_kind` (null for a non-member, absent on an older server), `MyDevice` gains +`member_sign_ins`, the revoke's answer is `MyDeviceRevoked` (`{ current, +members_signed_out? }`), and `Capabilities` +gains `household_members`. + +`client.ts`: `members`, `addMember`, `memberSession`, `memberPairing`, +`memberDevices`, `revokeMemberDevice`, `updateMember`, `deleteMember`, and +`revokeMyDevice(id, { alsoMembers })`, which adds `?also_members=1` only when asked +(the old one-argument call is unchanged). `hooks.ts`: `useHouseholdMembers` and +`useMemberDevices(id)` (queries gated on `useCapability('household_members')` with +`skipToken`), and the mutations `useAddMember`, `useMemberSession`, +`useMemberPairing`, `useRevokeMemberDevice`, `useUpdateMember` and `useDeleteMember` +(each rejects with `CapabilityError` while the flag is off or unknown). They refresh +the members list, that member's devices and, when a sign-in on this device appears or +goes, the caller's own devices (whose rows carry `member_sign_ins`). +`useRevokeMyDevice` takes `number | { id, alsoMembers }` (`RevokeMyDeviceVars`), +sending `alsoMembers` only to a server with the flag. + +- **The members list never refuses an ordinary session**: a demo or member caller + gets `allowed: false` with a `reason`, and "at the cap" is the client's own + `members.length >= max` (`addBlock` in `src/components/household/household-model.ts`). +- **A member's session is placed with `addMemberHere`**, never `finishConnect` + (see [Profiles](profiles.md#signing-a-member-in-here-addmemberhere)): the owner + stays the person listening and the default connection doesn't move. +- **The kid's view is the server's**: the player reads `member_kind` only to start + kids mode by default and `managed_by` only to pick the kids shelf's collections + ([Kids mode](kids-mode.md)); what the account may open is always the server's + answer. + ### Bookmarks, notes and the Journal The server's `annotations` capability, mirrored like the 1b state: every hook takes an @@ -664,7 +701,9 @@ downloads and saves stay reachable); a new one gets `connectionIdOf(serverId, user.id)`, and `setSession` returns the id it used. It merges the addresses it is given with what the device knew (the prior connection's, else another user's connection to the same server, else the remembered server's; `mergeAddresses`), makes -it the default, remembers the server durably without its token +it the default (unless the caller passes `{ makeDefault: false }`, as `addMemberHere` +does for a household member signed in beside the owner; it still becomes the default +when there is none, or the current default is no longer signed in), remembers the server durably without its token (`src/lib/known-servers.ts`, the connect screen's Reconnect rows, keyed by `server_id` with no user). A connection at the same URL **whose `server_id` differs** (`staleIdentities`: a rebuilt server mints a new `server_id` at the same address) is a diff --git a/docs-developers/frontend/you-and-settings.md b/docs-developers/frontend/you-and-settings.md index 5c1f4f1..d919162 100644 --- a/docs-developers/frontend/you-and-settings.md +++ b/docs-developers/frontend/you-and-settings.md @@ -1,6 +1,6 @@ --- title: You, Settings and Account -description: "The You hub (src/components/you/): its routing and sections, Your listening (the stats model and the react-native-svg charts), Year in listening (the story, the /year root modal, sharing a card as an image under the /web CSP), Settings (src/components/settings/) and a server's Account page (src/components/account/)." +description: "The You hub (src/components/you/): its routing and sections, Your listening (the stats model and the react-native-svg charts), Year in listening (the story, the /year root modal, sharing a card as an image under the /web CSP), Settings (src/components/settings/), the Household section, and a server's Account page (src/components/account/)." --- The **You** destination is the Me tab's root: one hub over the listener's own data and @@ -13,23 +13,26 @@ section component, so each can be read (and tested) on its own: | Year ("Year in listening") | `YearSection()` | `src/components/you/year/` | | Journal | `JournalScreen` (`embedded` on a phone) | `src/components/journal/` ([The Journal](journal.md)) | | Settings | `SettingsContent({ section?, onSectionChange?, embedded? })` | `src/components/settings/` | +| Household (phone only; elsewhere the `/household` page) | `HouseholdPage({ embedded })` | `src/components/household/` ([Profiles](profiles.md#the-household-page)) | | Account | `AccountSection({ connectionId? })` | `src/components/account/` | The user-facing behaviour is in the User Guide: [You and your listening](/users/listening/you), [Year in listening](/users/listening/year-in-listening), -[Settings](/users/listening/settings) and [Your account on a server](/users/listening/account). +[Settings](/users/listening/settings), [Your household](/users/listening/household) and [Your account on a server](/users/listening/account). ## The hub and its routes -`/you?section=stats|year|journal|settings|account` is `src/app/(app)/(me)/you.tsx`, a +`/you?section=stats|year|journal|settings|household|account` is `src/app/(app)/(me)/you.tsx`, a re-export of `YouHub` (`src/components/you/you-hub.tsx`). The rules are the pure `you-model.ts`: - `parseYouSection(raw)`: absent, unknown or repeated is `stats`. -- `youSectionsFor(layout)`: a phone's segmented control offers all five sections (it has - no top bar); tablet and desktop offer Stats, Year in listening and Journal, because - Settings is the top bar's gear and Account the profile menu. A link to `settings` or - `account` still renders there, with no segment chosen. +- `youSectionsFor(layout)`: a phone's segmented control offers all six sections (it has + no top bar), Household between Settings and Account; tablet and desktop offer Stats, + Year in listening and Journal, because Settings is the top bar's gear and Household + and Account the profile menu (Household there is the pushed `/household` page, + decision 4 of Phase 8b-1). A link to `settings`, `household` or `account` still + renders there, with no segment chosen. - `youSectionLabelKey` / `youTitleKey`: "Year" on a phone, "Year in listening" in the wide sub-nav; the phone's large title follows the section (`navigation.setOptions`), the wide sub-nav keeps "You". @@ -61,6 +64,11 @@ top bar's gear, the profile menu and the palette's Go to. Back returns where the was, and a cold link lands in Home. On the Settings page already, `openSettings` only moves the pane. +**Household** is the same kind of page, `/household`, pushed through `openHousehold()` +(the profile menu, "Who's listening?" > Manage household, Settings, Home's house row, +the palette); the phone hub's Household segment renders the same `HouseholdPage`. See +[The Household page](profiles.md#the-household-page). + **The year story** on a phone is the root modal `/year` (`src/app/year.tsx` → `YearStoryScreen`), opened with `router.push(yearHref({ year?, connection?, card? }))` like the player and the end credits. It never navigates into the shell. @@ -170,14 +178,17 @@ page's Automatic downloads card and the series page bind the same store values a shortcuts, never a second copy. The rules are the pure `settings-model.ts`: - Groups and panes: **Listening** (`playback`, `sleep`, `downloads` - "Up next and - downloads"), **App** (`appearance`, `language`, `household`), **Servers** (`accounts`, - `support`). `settingsGroups(supportAvailable)` drops Support where + downloads"), **App** (`appearance`, `language`), **Servers** (`accounts`, + `support`). The App group (`HOUSEHOLD_LINK_GROUP`) ends with a link to the Household + page where its pane used to be. `settingsGroups(supportAvailable)` drops Support where `isSupportAvailable()` says the build can't link to it (Apple builds). There is no Accessibility pane: the app has no setting of its own for it, and the style guide says - not to show a switch that does nothing. Household is `HouseholdPane`, the people on - this device and the house sharing switches (see [Profiles](profiles.md#settings--household-and-sharing)). + not to show a switch that does nothing. Household is a page of its own since Phase 8b-1 (see + [Profiles](profiles.md#the-household-page)). - `parseSettingsSection`: `preferences`, absent, unknown or a hidden pane is the first - pane (`playback`); `accounts` is the signed-in servers. `sectionParam` writes the bare + pane (`playback`); `accounts` is the signed-in servers. `household` + (`isHouseholdSection`) is the Household page: the route replaces itself with + `/household`. `sectionParam` writes the bare page for the first pane. - `settingsLayout(width, phone)`: from a **measured** 720 px (`SPLIT_MIN`) a grouped section nav beside one pane's card; narrower, every pane stacked under its group's @@ -222,3 +233,10 @@ final position, flush the queued progress) could run. This device signs out only counts the downloads that signing out deletes. Another device's row signs out through `useRevokeMyDevice` after a confirm, and a toast says what happened (signed out, already gone, or the server didn't answer). + +**A lost phone takes the household with it.** When another device's row carries +`member_sign_ins` (capability `household_members`: the listener's household members +signed in on that device, `memberSignIns` in `account-model.ts`), the confirm adds +"Also sign out Evie and Ellis on that device" (`joinNames`), **ticked**, fresh for +every device. Confirming sends `useRevokeMyDevice({ id, alsoMembers })` (the plain id +when there are no members, today's call), and the toast counts `members_signed_out`. diff --git a/docs-developers/server/api/index.md b/docs-developers/server/api/index.md index 61c1470..bb664d1 100644 --- a/docs-developers/server/api/index.md +++ b/docs-developers/server/api/index.md @@ -157,7 +157,7 @@ Failures a person can fix also carry a machine-readable **`code`** next to | `code` | Status | When | |---|---|---| -| `username_taken` | `409` | creating an account with a username that exists (`POST /admin/users`) | +| `username_taken` | `409` | creating an account with a username that exists (`POST /admin/users`); a household member whose name leaves no free username (`POST /me/household/members`) | | `name_taken` | `409` | a library or share name already in use (create, rename) | | `last_admin` | `409` | demoting or disabling the last enabled admin | | `admin_needs_password` | `400` | an admin without a password (creating one, promoting a password-less account, or clearing an admin's password) | @@ -190,6 +190,21 @@ Failures a person can fix also carry a machine-readable **`code`** next to | `backup_too_new` | `400` | `POST /admin/backups/{name}/restore` on a backup made by a newer server | | `invalid_target` | `400` | a notification destination the server refuses (`POST`/`PATCH /admin/notifications`); `field` names the field (`kind`, `name`, `url`, `secret`, `events`) | | `too_many_targets` | `409` | `POST /admin/notifications` when the server has 20 destinations | +| `demo_account` | `403` | a demo account on a route it can't use: the self-service routes (password, recovery code, API keys), sharing a collection, `GET /me/share-targets`, `PUT /me/household`, every `/me/household/members` route but the list | +| `kid_account` | `403` | a kid household member on `POST /auth/password`, `POST /auth/tokens`, `POST /auth/pair`, `POST /auth/recovery`, `PUT /me/household` or `PUT /me/collections/{id}/shares` | +| `member_cannot_manage` | `403` | a household member on a `/me/household/members` route | +| `feature_off` | `403` | adding a household member while `household.members_enabled` is off | +| `not_allowed` | `403` | adding a household member while the admin turned off the caller's `can_add_members` | +| `member_limit` | `409` | adding a household member at `household.max_members` | +| `invalid_name` | `400` | a household member's name that is empty, over 40 characters, or holds control characters | +| `invalid_collections` | `400` | a member's `collection_ids` naming a collection that isn't the caller's own | +| `member_not_admin` | `409` | `PATCH /admin/users/{id}` making a household member an admin, or assigning an admin as a member | +| `member_fields_required` | `400` | `PATCH /admin/users/{id}` with a `managed_by` id but not both `member_kind` and `member_access`, or either of those without it | +| `owner_is_member` | `409` | `PATCH /admin/users/{id}` assigning to an owner who is a household member | +| `owner_is_demo` | `409` | `PATCH /admin/users/{id}` assigning to a demo account | +| `self_managed` | `409` | `PATCH /admin/users/{id}` assigning a person to themselves | +| `has_members` | `409` | `PATCH /admin/users/{id}` assigning a person who has household members of their own | +| `invalid_keep_members` | `400` | `DELETE /admin/users/{id}?keep_members=` with an entry that isn't one of that person's members | **Branch on `code`, not on the English `error` text**, which is free to change. Errors without a `code` are ones a client can't help the person fix. @@ -204,9 +219,9 @@ Status mapping is consistent across handlers: |---|---| | `400` | malformed body / unknown JSON field, missing or invalid parameter (`path is required`, `invalid cursor`, non-integer `{id}`), path escaping the library root, domain validation (`mode must be "book" or "collection"`, admin needs a password, password too short) | | `401` | missing/invalid/expired token, bad credentials, invalid auth code, wrong `current_password` | -| `403` | authenticated but not allowed: no share grants the library or path, `admin only`, demo accounts on the self-service routes (password/recovery/API keys), an API key on a credential-minting route (create key/recovery/pair/password/cast token), bad setup token | +| `403` | authenticated but not allowed: no share grants the library or path, `admin only`, demo accounts on the self-service routes (password/recovery/API keys), an API key on a credential-minting route (create key/recovery/pair/password/cast token) or on a household member route, a kid household member on the routes a kid can't use, a member managing members, adding a member while it is switched off, bad setup token | | `404` | library/user/share/invite not found, another user's (or an unknown) bookmark or note on an edit, `no book at that path`, feature not configured (demo mode off, well-known files unset) | -| `409` | conflicts: `name already taken` (library/share), last-enabled-admin guard, signing out the device making the request, starting someone's progress on a book they can't see, setup already completed | +| `409` | conflicts: `name already taken` (library/share), last-enabled-admin guard, the household member cap and the admin's household refusals, signing out the device making the request, starting someone's progress on a book they can't see, setup already completed | | `413` | a request body over an endpoint's size cap (a custom cover over 5 MiB) | | `415` | an upload of a type the endpoint doesn't take (a custom cover that isn't JPEG/PNG/WebP) | | `429` | a rate limiter tripped (see below) | @@ -322,7 +337,8 @@ single-shot with a `limit` and no pagination. "annotations": true, "addresses": true, "household": true, - "cast": true + "cast": true, + "household_members": true }, "auth": { "methods": ["auth_code", "password"] }, "demo": { "enabled": false } @@ -381,6 +397,12 @@ The player redesign's Phase 8 added two, each always `true` on a server that has | `household` | [Listening in the house](reference.md#listening-in-the-house): the caller's opt-in at [`/me/household`](reference.md#get-apiv1mehousehold--put-apiv1mehousehold) and [`GET /household/listening`](reference.md#get-apiv1householdlistening). Without it the player hides the sharing switch and the Home row | | `cast` | [`POST /cast/token`](reference.md#post-apiv1casttoken) and a cast token as the `?token=` of a book's cover and stream. Without it the player shows no Cast button | +Phase 8b-1 added one more, always `true` on a server that has it: + +| Flag | Gates | +|---|---| +| `household_members` | [Household members](reference.md#household-members): `/me/household/members/**`, `managed_by` and `member_kind` on the `user` object, `member_sign_ins` on [`/me/devices`](reference.md#get-apiv1medevices) and its `?also_members=1`. Without it the player offers no "Add someone to your household". It never depends on the caller: whether this caller may add is the members list's `allowed` | + Gate on the flag rather than on the server version: an older server answers `size=` and `narrator=` by ignoring them (full art, the unfiltered list) and the new routes with a `404`. Request bodies are decoded strictly, so a field added to an diff --git a/docs-developers/server/api/reference.md b/docs-developers/server/api/reference.md index b01ff6a..b80c839 100644 --- a/docs-developers/server/api/reference.md +++ b/docs-developers/server/api/reference.md @@ -57,7 +57,8 @@ else and gate features on the flags. "annotations": true, "addresses": true, "household": true, - "cast": true + "cast": true, + "household_members": true }, "auth": { "methods": ["auth_code", "password"] }, "demo": { "enabled": false } @@ -132,6 +133,15 @@ has them and absent on an older one: | `household` | always `true` | [Listening in the house](#listening-in-the-house): `GET`/`PUT /me/household` and `GET /household/listening` | | `cast` | always `true` | [Casting](#casting): `POST /cast/token`, and a cast token as the `?token=` of a book's [cover](#get-apiv1librariesidcover) and [stream](#get-apiv1librariesidstream) | +Phase 8b-1 adds one more, always `true` on a server that has it and absent on an older one: + +| Flag | Value | Gates | +|---|---|---| +| `household_members` | always `true` | [Household members](#household-members): `/me/household/members/**`; `managed_by` and `member_kind` on the [`user`](#get-apiv1me); `member_sign_ins` and `?also_members=1` on [Your devices](#your-devices) | + +The flag never depends on the caller or on the admin's switches: whether **this** +caller may add a member is the members list's `allowed`. + ### `GET /healthz` · `GET /api/v1/healthz` *Public.* Liveness/readiness probe: checks database read-reachability under a @@ -328,7 +338,9 @@ on a server with that capability - the same shape as `/auth/exchange`. device" from an existing session. No request body. Response `200`: a `PairingPayload` (same shape as `/auth/redeem`, without the invite budget fields). Its token is single-use and lasts 10 minutes (`pairingTTL`), so the player counts down from -when the payload arrived. +when the payload arrived. A kid's account (a [household member](#household-members) +with `member_kind: "kid"`) gets `403` `code: "kid_account"`: its owner sets up its +devices with [`POST /me/household/members/{id}/pair`](#post-apiv1mehouseholdmembersidpair). ### `POST /api/v1/auth/logout` @@ -348,13 +360,24 @@ Content`. "has_password": true, "has_recovery": false, "is_demo": false, - "last_seen_at": "2026-07-02T08:15:00Z" + "last_seen_at": "2026-07-02T08:15:00Z", + "managed_by": null, + "member_kind": null } ``` `last_seen_at` is derived from the account's most recent token activity and is omitted when there is none. `role` is `"admin"` or `"user"`. +On a server with `household_members`, `managed_by` and `member_kind` are always +present: both `null` for an account nobody manages, and for a +[household member](#household-members) the owner (`{ "id": 4, "username": "sam" }`) +and `"kid"` or `"grownup"`. The same `user` object rides on every session envelope +(exchange, login, demo, a member's session). The member's `member_access` and +display name are not on it: they are the owner's business (the +[members list](#get-apiv1mehouseholdmembers)) and the admin's. An older player +ignores both fields; the server limits a kid whatever the player shows. + ### `GET /api/v1/addresses` *Session* (any role, demo accounts included). Capability `addresses`. The server's @@ -376,8 +399,11 @@ Either field is omitted when unknown, and the answer is `{}` when neither is. The routes in this section, together with the [API keys](#personal-api-keys) below, share one rate limit (10 attempts per IP per 15 minutes) and are -**refused for demo accounts** (403), so a throwaway session can't mint a durable -credential. +**refused for demo accounts** (403 `code: "demo_account"`, `not available for demo +accounts`), so a throwaway session can't mint a durable credential. `POST +/auth/password`, `POST /auth/recovery` and `POST /auth/tokens` (and `POST /auth/pair`) +are also refused for a kid's account (403 `code: "kid_account"`, `not available for a +kid's account`): a kid signs in only on devices its owner sets up. ### `POST /api/v1/auth/password` @@ -394,7 +420,7 @@ Response: `204 No Content`. |---|---| | `400` | password missing or too short | | `401` | `current_password` incorrect | -| `403` | demo account | +| `403` | demo account (`demo_account`); a kid's account (`kid_account`) | | `429` | account-mutation limit tripped | ### `POST /api/v1/auth/recovery` @@ -416,7 +442,7 @@ Response `201`: { "recovery_code": "H7XD-4WQN-C9K2-TMPV" } ``` -`403` for demo accounts, `429` on the shared limit. +`403` for demo accounts (`demo_account`) and kids (`kid_account`), `429` on the shared limit. ### `DELETE /api/v1/auth/recovery` @@ -474,7 +500,7 @@ authenticates a request. | Status | Meaning | |---|---| | `400` | `label` missing/empty, or longer than 100 characters | -| `403` | demo account, or the caller is itself authenticating with an API key (containment - a key can't mint another) | +| `403` | demo account (`demo_account`), a kid's account (`kid_account`), or the caller is itself authenticating with an API key (containment - a key can't mint another) | | `429` | account-mutation limit tripped | ### `GET /api/v1/auth/tokens` @@ -532,7 +558,8 @@ first (`auth.ListDevices` for the caller). "created_at": "2026-07-02T11:20:31Z", "last_seen": "2026-10-04T19:41:56Z", "last_ip": "192.168.1.24", - "current": true + "current": true, + "member_sign_ins": [ { "id": 12, "name": "Evie" } ] } ] } @@ -545,15 +572,31 @@ reported (`null` until a request names one), `last_seen` the newest authenticated request (`null` before any), `last_ip` that request's address, and `current` marks the token making this request. +`member_sign_ins` (capability `household_members`; always present there, `[]` when +none) names the caller's own [household members](#household-members) holding a live +session or API key that was made **on that device**: a member signed in through +`sign_in_here` or [`…/members/{id}/session`](#post-apiv1mehouseholdmembersidsession) +records the caller's token as its `tokens.created_on_token_id`. `name` is the member's +display name, else their username. Someone else's members never appear, and a +member's own device (paired from its code) is not "made on" any of the caller's. + ### `DELETE /api/v1/me/devices/{id}` *Session.* Signs one of the caller's devices out by revoking its token. The caller's other devices stay signed in. ```json -{ "current": false } +{ "current": false, "members_signed_out": 2 } ``` +`?also_members=1` (or `true`; any other value is off) also revokes, in the same +transaction, every session and API key of the caller's own household members whose +`created_on_token_id` is that device: the "Also sign out Evie and Ellis on that +device" of a lost phone. `members_signed_out` counts the **members** signed out (not +their tokens): always present, `0` without the parameter. A member's sign-ins made +anywhere else stay. The parameter is only meaningful on a server with +`household_members`; an older server ignores it and answers `{ "current": … }`. + Revoking the **current** device is allowed (unlike the admin route's `409 current_device`): the response says `"current": true`, and the token is dead for every later request. A player shouldn't offer it for that row, though: @@ -568,7 +611,7 @@ written to the admin audit log. | Status | Meaning | |---|---| -| `200` | revoked; `current` says whether it was the token making this request | +| `200` | revoked; `current` says whether it was the token making this request, `members_signed_out` how many members went with it | | `400` | `invalid device id` - not a number | | `404` | `device not found` - not one of the caller's session or API-key tokens: another user's, unknown, a pairing token or already revoked (never `403`, so the answer doesn't confirm that another user's token exists) | @@ -2479,14 +2522,17 @@ Content`, idempotent, with no access check on the path. `403` `400 user_ids is required`). Each id must be an existing, enabled, non-demo account other than the owner, or someone it is already shared with (a viewer disabled since stays valid, so the `shared_with` the owner was shown can be sent -back); a new disabled, demo or unknown id rejects the whole request. After the +back); a new disabled, demo or unknown id rejects the whole request. A kid (a +[household member](#household-members) with `member_kind: "kid"`) is a valid id only +for their own owner: from anyone else it is `unknown user`, whatever the collection +held before, so the answer never says who is a kid. A kid caller can't share at all. After the demo check and the body, ownership is settled before the ids are checked. Response `200`: `{ "collection": { … } }`, its `shared_with` updated (`updated_at` doesn't move). | Status | Meaning | |---|---| | `400` | `invalid request` (a malformed body); `user_ids is required`; `unknown user`; `a collection can be shared with at most 50 users` (distinct ids) | -| `403` | `not available for demo accounts` - the caller is a demo account (checked first, so even for a collection that isn't theirs); or `code: "not_owner"` | +| `403` | `code: "demo_account"` - the caller is a demo account (checked first, so even for a collection that isn't theirs); `code: "kid_account"` - the caller is a kid; or `code: "not_owner"` | | `404` | not the caller's collection and not shared with them | A viewer sees the owner's username and the items **they** can open; nothing of @@ -2495,7 +2541,8 @@ the owner's access is shared with them. ### `GET /api/v1/me/share-targets` *Session.* Capability `collections`. Who the caller can share a collection -with: every enabled, non-demo account other than the caller, by username. +with: every enabled, non-demo account other than the caller, by username. A kid +household member is listed only to their own owner. ```json { "users": [ { "id": 6, "username": "alex" }, { "id": 9, "username": "robin" } ] } @@ -2505,7 +2552,7 @@ This is the one place a non-admin can list every other account's username (a shared collection also shows its owner, and [`GET /household/listening`](#get-apiv1householdlistening) the other people who share). A demo -account gets `403` `not available for demo accounts`, so a public demo can't +account gets `403` `code: "demo_account"`, so a public demo can't list the server's usernames. ### `GET /api/v1/libraries/{id}/rating` @@ -2720,9 +2767,11 @@ is in the foreground). No admin route reads or changes anyone's choice. `PUT` takes the same shape, `{ "share_listening": true }`, and answers `200` as `GET`. The field is required and the body is decoded strictly: a missing field (`{}`), an unknown field or a wrong type is `400` `invalid request`. A demo account's -`PUT` is `403` (`not available for demo accounts`; its `GET` works and reads +`PUT` is `403` (`code: "demo_account"`; its `GET` works and reads `false`), so a public demo never shows up in anyone's house. A personal API key may -set it: it is the person's own credential and mints nothing. +set it: it is the person's own credential and mints nothing. A kid's account +(a [household member](#household-members)) gets `403` `code: "kid_account"` on `PUT`, +and its `GET` always reads `false`, even if it shared before it was made a kid. ### `GET /api/v1/household/listening` @@ -2751,6 +2800,8 @@ set it: it is the person's own credential and mints nothing. ``` - `sharing` - whether the caller shares. While it is `false`, `listening` is `[]`. + A kid household member never shares and never sees anyone (`{ "sharing": false, + "listening": [] }`), and never appears as a row. - `state` - from the age of the person's newest save: `playing` within 60 seconds, `paused` within 10 minutes (the session gap), else `recent`. - `title` - the index's title, else the path's last segment (a book no longer @@ -2763,6 +2814,202 @@ set it: it is the person's own credential and mints nothing. which. - `updated_at` - the newest save, fixed-width millisecond UTC. +## Household members + +*Session.* Capability `household_members`. Any non-demo person (the **owner**) adds +the people they live with from the player, with no admin and no invite. A **member** +is a real account (`role: "user"`, no password) whose `users.managed_by` is the owner, +with a `member_kind` (`kid` or `grownup`) and a `member_access` (`all` or +`collections`) that only the owner sets. The accounts live in `internal/auth/members.go`, +what a member may listen to in `catalog` (the member rule; see +[Auth & security](../auth-and-security.md#household-members)), and the transport in +`internal/api/handlers_members.go`. + +- **`all`**: the owner's current access, live (an admin owner gives every library, as + content scope only; the member never gets the admin role). +- **`collections`**: the books in collections the owner owns and shares with the + member, each only while the owner's current access allows it. +- Either way, plus the member's own admin grants. +- One level: an owner is never a member, a member never has members and is never an + admin. + +**Guards on every route below:** an API key is `403` (`denyAPIKey`; the list too), a +demo account `403` `code: "demo_account"`, a member caller `403` `code: +"member_cannot_manage"`, anonymous `401`. Someone else's member (or no such account) is +`404` `member not found` on every `{id}` route, so the answer never says whether the id +exists. Only **adding** checks the server's switch, the owner's switch and the cap: +every other route keeps working on the caller's existing members when the feature is +off, so an owner can always rename, sign out and delete them. Create, session and pair +also take the [self-service](#self-service-account) rate limit (`gateSelfService`). The +bodies are decoded strictly: an unknown field (such as `role`) is `400 invalid +request`. + +**The member object** (`HouseholdMember`), as the list, the create and the `PATCH` +answer it: + +```json +{ + "id": 12, + "name": "Evie", + "username": "evie", + "kind": "kid", + "access": "collections", + "created_at": "2026-10-13T10:00:00.000Z", + "collections": [ { "id": 4, "name": "Evie's books", "items": 2 } ], + "devices": 1, + "last_listened_at": "2026-10-13T18:00:00.000Z" +} +``` + +- `name` - the name the owner typed (`users.display_name`), else the username. + `username` is derived from it at create (below) and never changes with a rename. +- `collections` - present (maybe `[]`) for `access: "collections"`, absent for `all`: + the owner's collections shared with the member, `items` counting the books the + **owner** can reach now. +- `devices` - how many live sessions and API keys the member has. +- `last_listened_at` - the member's newest listening, `null` when never. +- `created_at` - fixed-width millisecond UTC. The list is ordered by it, then `id`. + +### `GET /api/v1/me/household/members` + +The caller's members and whether they may add one: + +```json +{ "allowed": true, "max": 6, "members": [ { "id": 12, "name": "Evie", "…": "…" } ] } +``` + +It never refuses an ordinary signed-in session: when the caller may not add, +`allowed` is `false` with a `reason`, checked in this order: + +| `reason` | Why | +|---|---| +| `demo` | a demo account (`members` is `[]`) | +| `member` | the caller is a household member (`members` is `[]`) | +| `feature_off` | the server's `household.members_enabled` is off | +| `not_allowed` | the admin turned off the caller's `can_add_members` | + +`max` is `household.max_members`. Being at the cap is **not** a reason: the client +compares `members.length` with `max`. An API key gets `403`. + +### `POST /api/v1/me/household/members` + +Adds a member. + +| Field | Type | Required | Notes | +|---|---|---|---| +| `name` | string | yes | trimmed; 1-40 characters, no control characters | +| `kind` | string | yes | `"kid"` or `"grownup"` | +| `access` | string | yes | `"all"` or `"collections"` | +| `sign_in_here` | bool | no | also sign the member in on the caller's device | +| `collection_ids` | int[] | no | the caller's own collections to share with the member (below) | + +In one transaction it checks the caller may add (demo, member, the server's switch, +the caller's switch, the cap, in that order), derives the username, inserts the +account (`role` user, an empty password hash, the three member columns and the typed +name), shares the collections and, with `sign_in_here`, issues a session. + +- **The username** is the name lowercased and folded to ASCII letters and digits + (`Évie Marie` -> `evie-marie`, any run of anything else one `-`, at most 24 bytes, + `member` when nothing is left), then that with the owner's username (`evie-sam`), + then numbered `-2` to `-99`: the first one no account has, ignoring case. Past `-99` + it is `409` `code: "username_taken"`. +- **`sign_in_here`** issues a session for the member named as the caller's own + device, recording the caller's token as its `created_on_token_id` (so the caller's + [device list](#get-apiv1medevices) names the member under `member_sign_ins`, and + [`?also_members=1`](#delete-apiv1medevicesid) can sign it out). +- **`collection_ids`** is the full set of the caller's collections to share with the + member (any `access`; the player sends it for `collections`). An id that isn't one of + the caller's own collections is `400 invalid_collections` and nothing is applied. The + [50-person share cap](#put-apiv1mecollectionsidshares) applies. `[]` is allowed: a + kid can start with nothing. + +Response `201`: + +```json +{ "member": { "id": 12, "name": "Evie", "…": "…" }, + "session": { "token": "…", "user": { "id": 12, "username": "evie", "managed_by": { "id": 4, "username": "sam" }, "member_kind": "kid", "…": "…" }, "server_id": "…" } } +``` + +`session` (only with `sign_in_here`) is the usual session envelope, the same shape as +[`/auth/exchange`](#post-apiv1authexchange)'s, `addresses` included where the server +has them. A kid is meant to be signed in here; a grown-up is usually shown a pairing +code instead. + +| Status | Meaning | +|---|---| +| `201` | `{ member, session? }` | +| `400` | `invalid request` (malformed, an unknown field, a wrong type); `code: "invalid_name"`; a `kind` or `access` that isn't one of the two values (no `code`); `code: "invalid_collections"`; `a collection can be shared with at most 50 users` (a chosen collection already has 50 other people) | +| `401` | anonymous | +| `403` | API key; `demo_account`; `member_cannot_manage`; `code: "feature_off"`; `code: "not_allowed"` | +| `409` | `code: "member_limit"` (at `max`); `code: "username_taken"` (no free username for the name) | +| `429` | the self-service rate limit | + +### `POST /api/v1/me/household/members/{id}/session` + +No body. Signs the member in on the caller's device, as `sign_in_here` does (named as +the caller's device, `created_on_token_id` the caller's token). Response `201`: the +session envelope. Used for "They'll also use this phone" and "Sign in on this +device". + +### `POST /api/v1/me/household/members/{id}/pair` + +No body. Response `200`: a `PairingPayload` for the **member's** own device, built like +[`/auth/pair`](#post-apiv1authpair)'s: single use, 10 minutes, unlinked. It works for a +kid (who can't call `/auth/pair` itself). A device that signs in with it is the +member's own: it records no `created_on_token_id`. + +### `GET /api/v1/me/household/members/{id}/devices` + +Response `200`: a bare array of the member's live sessions and API keys, each shaped as +a [`GET /me/devices`](#get-apiv1medevices) row with `current` always `false` and no +`member_sign_ins` (`[]` when none). + +### `DELETE /api/v1/me/household/members/{id}/devices/{device_id}` + +Signs the member out of one device. `204`; `404` `device not found` for a device that +isn't the member's live session or API key, `400` for an id that isn't a number. + +### `PATCH /api/v1/me/household/members/{id}` + +Changes a member: any of `name`, `kind`, `access` and `collection_ids` (the same rules +as the create; `collection_ids` replaces the full set: a share on each listed +collection, and the member removed from every other collection the caller owns; +other people's collections are never touched). `{}` is `400` `nothing to change`. +Response `200`: the member object. + +Switching a grown-up to a **kid** clears, in the same transaction, the member's +password (and the browsers it signed in from), revokes their API keys and unused +pairing tokens, and deletes their recovery code. Their signed-in sessions stay signed +in. Switching kind leaves `access` as it was. + +| Status | Meaning | +|---|---| +| `200` | the member, as it is now | +| `400` | `invalid request`, `nothing to change`, `invalid_name`, a bad `kind` or `access`, `invalid_collections`, `a collection can be shared with at most 50 users` | +| `404` | not the caller's member | + +### `DELETE /api/v1/me/household/members/{id}` + +Deletes the member's account and all of its state (the schema's cascade, as +[`DELETE /admin/users/{id}`](#delete-apiv1adminusersid)). `204`. + +### What a member may and may not do + +| Route | A grown-up member | A kid | +|---|---|---| +| browse, search, play, progress, bookmarks, notes, Up next, ratings, a cast token | yes, within their access | yes, within their access | +| their own collections (`POST /me/collections`, items) | yes | yes | +| `PUT /me/collections/{id}/shares` | yes | `403 kid_account` | +| `POST /auth/password`, `POST /auth/tokens`, `POST /auth/pair`, `POST /auth/recovery` | yes | `403 kid_account` | +| `PUT /me/household` | yes | `403 kid_account` (`GET` reads `false`) | +| `/me/household/members/**` | `403 member_cannot_manage` | `403 member_cannot_manage` | +| `/admin/**` | `403` (role `user`) | `403` (role `user`) | + +A kid's password sign-in fails like any password-less account's (`401` on +[`/auth/login`](#post-apiv1authlogin)). **Audit:** each route that changes something +writes a `member.*` event with the owner as actor and the member as target (see +[Audit log](../backups-and-notifications.md#audit-log)). + ## Casting *Session.* Capability `cast`. A Chromecast receiver plays a book from URLs the @@ -2827,12 +3074,32 @@ All accounts, wrapped as `{ "users": [ … ] }`: "has_password": false, "has_recovery": true, "is_demo": false, - "last_seen_at": "2026-07-02T08:15:00Z" + "last_seen_at": "2026-07-02T08:15:00Z", + "managed_by": null, + "member_kind": null, + "member_access": null, + "can_add_members": true, + "members_count": 2, + "display_name": "" } ] } ``` +Each user is the [`/me`](#get-apiv1me) object plus the household member fields +(capability `household_members`) only the admin sees: + +| Field | Meaning | +|---|---| +| `managed_by` | `{ id, username }` of the owner for a [household member](#household-members), else `null` | +| `member_kind` | `"kid"` or `"grownup"` for a member, else `null` | +| `member_access` | `"all"` or `"collections"` for a member, else `null` | +| `can_add_members` | the admin's per-person switch (default `true`) | +| `members_count` | how many members this person has | +| `display_name` | the name the owner typed for a member (`""` for everyone else; kept when the member is made independent) | + +The same fields are on `GET /admin/users/{id}`'s `user` and on the `PATCH` answer. + ### `POST /api/v1/admin/users` Create an account. @@ -2873,10 +3140,19 @@ One account plus everything the console needs to manage it: "redeemed_at": "2026-07-02T11:20:31Z", "created_at": "2026-07-02T10:00:00Z" } - ] + ], + "members": [ { "id": 12, "username": "evie", "name": "Evie", "kind": "kid", "access": "collections", "disabled": false } ], + "member_collections": [] } ``` +`members` is this person's own household members (oldest first; `[]` for none). +`member_collections` is, for a member on `access: "collections"`, the owner's +collections shared with them (`{ id, name, items }`, `items` counted by the owner's +current access), else `[]`. `accessible_libraries` includes a member's **derived** +access (the owner's libraries for `all`, the libraries their shared collections reach +for `collections`), while `shares` stays the admin's direct grants only. + `shares` marks whole-library grants with `whole_library_id` (see [`GET /admin/shares`](#get-apiv1adminshares)). `auth_codes` is **invite metadata only** (never the code itself, and never recovery codes - recovery presence surfaces as `user.has_recovery`). @@ -2892,14 +3168,34 @@ Edit an account in place - any subset of: | `role` | string | `"admin"` \| `"user"` | | `password` | string | `""` clears the password (non-admins only) | | `disabled` | bool | reversible lockout; disabling revokes nothing but blocks all token use | +| `can_add_members` | bool | the per-person switch: may this person add household members | +| `managed_by` | int \| `null` | an owner's id: "Assign to someone", with `member_kind` and `member_access` beside it. `null`: "Make independent" | +| `member_kind` | string | `"kid"` \| `"grownup"`; only with a `managed_by` id | +| `member_access` | string | `"all"` \| `"collections"`; only with a `managed_by` id | + +The body is decoded strictly. The password is applied first, then the member fields, +then the role (so "Make independent" and "make admin" work in one request). + +- **Assign to someone** (`managed_by: `) makes the account a member of that owner + with the given kind and access. Its own grants stay (the member rule is a union). + Neither the owner's cap nor their switch applies. As a kid, the account's password, + API keys, unused pairing tokens and recovery code are cleared; its sessions stay. + Moving a member to another owner works the same way. +- **Make independent** (`managed_by: null`) clears the three member columns: the + account keeps only its direct grants, and `display_name`. A no-op on an account + that isn't a member. -Response `200`: the updated user object. +Response `200`: the updated user object (with the fields above). | Status | Meaning | |---|---| -| `400` | admin must keep a password (`admin_needs_password`) / password too short (`password_too_short`) | -| `404` | user not found | -| `409` | would demote/disable the last enabled admin (`last_admin`) | +| `400` | admin must keep a password (`admin_needs_password`) / password too short (`password_too_short`); `managed_by` that isn't a positive id or `null`; `code: "member_fields_required"` (`managed_by: ` without both `member_kind` and `member_access`, or either of those without it); a `kind` or `access` that isn't one of the two values | +| `404` | user not found (the account, or the owner named by `managed_by`) | +| `409` | would demote/disable the last enabled admin (`last_admin`); `code: "member_not_admin"` (making a member an admin, or assigning an admin as a member); `code: "owner_is_member"` (the owner is a member); `code: "owner_is_demo"` (the owner is a demo account); `code: "self_managed"` (`managed_by` is the account itself); `code: "has_members"` (the account has members of its own) | + +`user.update` in the audit log gains `can_add_members`, and `managed_by` (the owner's +username, or `null`), `member_kind` and `member_access` when an Assign or Make +independent changes them. ### `DELETE /api/v1/admin/users/{id}` @@ -2908,9 +3204,15 @@ codes, progress, bookmarks, notes, history, listening sessions and daily listening totals, share grants) via cascade; files on disk are untouched. Response: `204 No Content`. +The person's [household members](#household-members) are deleted with them, in the +same transaction, except those named in `?keep_members=,`: each kept member +becomes an ordinary account (the three member columns cleared, so only their direct +grants remain). The audit event `user.delete` carries `members_deleted` (a count) and +`members_kept` (usernames). + | Status | Meaning | |---|---| -| `400` | self-delete refused (disable your own account instead) - `cannot_delete_self` | +| `400` | self-delete refused (disable your own account instead) - `cannot_delete_self`; `code: "invalid_keep_members"` (a `keep_members` entry that isn't a positive id, or isn't one of this person's members: nothing is deleted) | | `404` | user not found | | `409` | last enabled admin - `last_admin` | @@ -5691,6 +5993,10 @@ and `AUDIOSILO_*` variable each one is, and whether it needs a restart (see "max_users_default": 200, "idle_ttl": "24h" }, + "household": { + "members_enabled": true, + "max_members": 6 + }, "backups": { "schedule": "daily:03:00", "keep": 7, @@ -5722,6 +6028,8 @@ effect. Lists are never `null`. | `metadata.available` | read-only: a metadata service exists (`base_url` was a valid absolute `http(s)` URL when the server started), so `enabled` can be turned on. The live `metadata` [capability](#get-apiv1server) is `enabled && available` | | `demo.max_users` | `null` = the default cap (`max_users_default`); `0` = no limit | | `demo.idle_ttl` | `""` = 24h | +| `household.members_enabled` | people may add [household members](#household-members) from the player (default `true`); off refuses new ones only (`403 feature_off`) | +| `household.max_members` | members per person, 1-50 (default 6) | | `backups.schedule` | `""` (off), `daily:HH:MM` or `weekly:DAY:HH:MM` in the server's time zone; stored in canonical form (see [Backups](#admin-backups)) | | `backups.keep` | how many scheduled backups are kept, 1-365 (manual and before-restore backups stay until deleted) | | `backups.dir` | read-only: changed only in `config.yaml` or `AUDIOSILO_BACKUP_DIR`; `""` = `/backups` (`GET /admin/backups`' `status.dir` has the folder in use) | @@ -5758,7 +6066,7 @@ written so a form can show it under the field: |---|---|---| | `200` | | saved; body is the settings envelope | | `400` | | not a JSON object of sections | -| `400` | `invalid_setting` | a value of the wrong type, one its normalizer refuses, a config that doesn't validate (e.g. `tls_mode: "autocert"` without `tls_hosts`, demo on without a library), a `demo.library` that names no library (`field: "demo.library"`), a change that would leave `config.yaml` invalid on its own because a value it needs comes only from an `AUDIOSILO_*` variable (the file keeps its own values for those; e.g. `demo.enabled: true` while the library is only in `AUDIOSILO_DEMO_LIBRARY`; the `error` names the variable), a `general.session_days` outside 30-3650, or turning `metadata.enabled` on when no metadata service exists (`available` is false; `field: "metadata.enabled"` - set a valid `metadata.base_url` and restart first). Also JSON `null` for a setting that can't be unset (anything but `demo.max_users`, whose `null` means the default), refused with "enter a value" | +| `400` | `invalid_setting` | a value of the wrong type, one its normalizer refuses, a config that doesn't validate (e.g. `tls_mode: "autocert"` without `tls_hosts`, demo on without a library), a `demo.library` that names no library (`field: "demo.library"`), a change that would leave `config.yaml` invalid on its own because a value it needs comes only from an `AUDIOSILO_*` variable (the file keeps its own values for those; e.g. `demo.enabled: true` while the library is only in `AUDIOSILO_DEMO_LIBRARY`; the `error` names the variable), a `general.session_days` outside 30-3650, a `household.max_members` outside 1-50, or turning `metadata.enabled` on when no metadata service exists (`available` is false; `field: "metadata.enabled"` - set a valid `metadata.base_url` and restart first). Also JSON `null` for a setting that can't be unset (anything but `demo.max_users`, whose `null` means the default), refused with "enter a value" | | `400` | `unknown_setting` | a section or name that isn't a setting (including the read-only extras such as `metadata.available`) | | `400` | `setting_read_only` | `players.web_dir` or `backups.dir` | | `409` | `setting_locked` | a setting an environment variable or the launcher sets (see `locked`) | @@ -6259,7 +6567,7 @@ is in [Audit log](../backups-and-notifications.md#audit-log). | Query param | Type | Default | Notes | |---|---|---|---| | `actor_id` | int | - | one account's actions | -| `area` | string | - | an action's first part (`user`, `invite`, `library`, `book`, `share`, `device`, `progress`, `issue`, `settings`, `backup`, `notify`); `[a-z_]{1,32}` | +| `area` | string | - | an action's first part (`user`, `member`, `invite`, `library`, `book`, `share`, `device`, `progress`, `issue`, `settings`, `backup`, `notify`, `import`); `[a-z_]{1,32}` | | `q` | string | - | the target or the actor's name contains it, ignoring case; at most 200 characters | | `before` | int | - | the previous page's `next_before` | | `limit` | int | `50` | at most 200 (a larger one is cut to 200; zero or less means 50) | @@ -6281,7 +6589,7 @@ is in [Audit log](../backups-and-notifications.md#audit-log). | Field | Meaning | |---|---| -| `actor_id`, `actor_name` | the admin, copied when it happened (kept when the account is renamed or deleted); `null` and `""` for the server itself | +| `actor_id`, `actor_name` | the admin (for a `member.*` event, the owner who did it in the player), copied when it happened (kept when the account is renamed or deleted); `null` and `""` for the server itself | | `via` | `session` (the console or another signed-in app), `api` (a personal API key) or `system` | | `action` | `.`, like `user.update`; a console words the ones it knows and shows the code for the rest | | `target` | what it was done to, as a person reads it: a username, a library's name, `: ` for a book, a backup's file name, a destination's name; `""` when the action has none | diff --git a/docs-developers/server/auth-and-security.md b/docs-developers/server/auth-and-security.md index 9abb9e1..46cc465 100644 --- a/docs-developers/server/auth-and-security.md +++ b/docs-developers/server/auth-and-security.md @@ -254,6 +254,10 @@ Three fences keep a demo session from becoming a durable login: (`GET /me/share-targets` and `PUT /me/collections/{id}/shares` are `403`; see [the listener's own state](#the-listeners-own-state-me)), and demo accounts are never offered as share targets. +- A demo account can't add [household members](#household-members) (every member + route but the list is `403`; the list answers `allowed: false`, `reason: "demo"`), + and can't be anyone's owner. +- Every demo refusal carries `code: "demo_account"` (`nonDemoAccount`). - A demo account can't share its listening with the house (`PUT /me/household` is `403`, `nonDemoAccount`), and demo accounts never appear in anyone's `GET /household/listening`, so a public demo never shows up in a household. It @@ -296,7 +300,15 @@ caller gets them: files on disk are untouched. - **Passwords are optional for non-admins** - stored as an empty hash, and `Authenticate` rejects empty-hash accounts outright, so a password-less user - can only ever authenticate via code pairing. + can only ever authenticate via code pairing (or, for a + [household member](#household-members), a session their owner makes). +- **`ErrMemberNotAdmin`** - a household member is never an admin: `SetRole` + refuses promoting one, and `AssignMember` refuses making an admin a member + (`409 member_not_admin`); "Make independent" comes first. +- **`DeleteUser(ctx, id, keep)`** deletes the person's household members with + them, in the same transaction, except the ids in `keep` (cleared to ordinary + accounts); a `keep` id that isn't their member is `ErrInvalidKeepMembers` and + nothing changes. The last-admin rule is checked first. ## Authorization: shares → Scope @@ -448,6 +460,87 @@ denied test: There is no admin route over these choices: an admin sees listening on the Activity pages as before, and nothing shows who shares. +## Household members + +Player redesign Phase 8b-1 (capability `household_members`) lets any non-demo person +(the **owner**) create accounts from the player, with no admin: a **member** is a +user with `managed_by` set to the owner, a `member_kind` (`kid` | `grownup`) and a +`member_access` (`all` | `collections`) only the owner sets. The routes and their +refusals are in [the reference](api/reference.md#household-members); this is the +security model. + +**One level, never admin.** An owner is never a member, a member never has members +(`ErrHasMembers`), and a member is never an admin (`ErrMemberNotAdmin`), so the +admin role is never derived. Callers of the scope functions keep passing `isAdmin = +u.Role == "admin"`; the owner's admin-ness is read inside the member rule, never from +the caller. + +**The member rule** (`catalog/members.go`, in `UserScope`, `UserScopes` and +`AccessibleLibraries`; decided per request, with no cache to invalidate). A member's +scope is the union of: + +- their own admin grants (unchanged); +- for `all`: the owner's **current** scopes, computed with the owner's role. An admin + owner gives every library as `AllowAll` content scope; +- for `collections`: the item paths of collections owned by `managed_by` and shared + with the member, each kept only while the owner's current scopes allow it + (`ScopesAllow`). A book is its path with `relpath.Within` semantics: a folder book + covers its own subtree, never a sibling (`Book` never reaches `Book 2`), and an + item with an empty path never grants a whole library. + +A member whose owner is themselves, or is a member (data the writes refuse), derives +nothing. A disabled owner changes nothing for their members. `UserShares` (the +console's Access tab) stays the direct grants only; the console lists the derived +access beside it. Every consumer follows from the three functions: browse, search, +media, cast mint and cast media, `/next`, the car and household listening. A book +removed from a kid's collection, the collection unshared or deleted, or a library +taken from the owner, is `403` on the next request, including through an existing +cast token (the media handler checks the person's current access). + +**Long path lists.** A `collections` member can hold a thousand item paths. Over 32 +paths, `pathFilterSQL`/`scopesFilterSQL` switch to a single-parameter form +(`pathsJSONFilterSQL`: a row's path and each of its ancestors looked up in one JSON +list, the same rows as the `OR` form, which could not hold 1,000 arms at all). +`BenchmarkMemberScopes` (3,000 books, a 1,000-item kid against a whole-library +listener) records the cost: browse 2.4 ms vs 0.27 ms, search 9.2 ms vs 5.6 ms, the +progress list 4.8 ms vs 1.7 ms. + +**Creating accounts without an admin** (`auth/members.go`, `CreateMember`, one +transaction): the owner is checked (not demo, not a member, the server's +`household.members_enabled`, their `can_add_members`, under `household.max_members`), +the name is validated, the username derived (never chosen by the caller, so it can't +claim an existing one), and the account inserted with `role = 'user'` and an empty +password hash. The body is decoded strictly, so a `role` field is `400`. An API key +can't reach any member route (`denyAPIKey`), and create, session and pair take the +self-service rate limit. + +**Kids.** A kid account holds no password, API key, pairing token or recovery code: +`POST /auth/password`, `POST /auth/tokens`, `POST /auth/pair` and `POST +/auth/recovery` answer `403 kid_account`, and switching a member to a kid (by the +owner, or the admin's Assign) clears all four in the same transaction +(`clearKidCredentials`), leaving its signed-in sessions alone. A kid signs in only on +devices the owner sets up: a session made on the owner's device, or the member +pairing code (`POST /me/household/members/{id}/pair`, single use, 10 minutes). +Turning kids mode off in a player never widens anything: the server decides access. + +**Visibility.** A kid is listed by `ShareTargets` only to their owner, and +`SetCollectionShares` refuses a kid target from anyone else with `ErrUnknownUser` +(the same answer as an unknown id, so it can't be used to probe who is a kid). A kid +can't share a collection (`403 kid_account`). `HouseListening` and +`HouseholdSharing` leave kids out both ways: never a row, never a viewer, never +sharing (see [what other people see](#listening-in-the-house-what-other-people-see)). + +**Ownership.** Every `{id}` member route loads the member through `memberOf`, which +answers `ErrNotMember` (404) for anyone who isn't the caller's member, so another +person's member is indistinguishable from no such account. + +**Sign-ins made on the owner's device.** A member session made through +`sign_in_here` or `…/session` records the owner's token in +`tokens.created_on_token_id`. It is information only (a token's validity never reads +it): signing the owner's device out leaves those sessions alone unless the owner asks +`?also_members=1`, which revokes them in the same transaction, restricted to +`managed_by = caller`. + ## Path traversal: `library.SafeJoin` Any filesystem access derived from user input goes through @@ -577,7 +670,8 @@ test.** A change that only proves the happy path can silently widen access. The enumerated critical list: - `library.SafeJoin` -- `Scope.Allows` / `Scope.VisibleInBrowse` / `pathFilterSQL` +- `Scope.Allows` / `Scope.VisibleInBrowse` / `pathFilterSQL` (its single-parameter form too) +- the household member rule (`catalog/members.go`) and the member routes - the rate limiters (`limiter`, `rateLimiter`) and the route classes in `rateLimit` - `auth.ResolveRequest` / `lookupToken` - the cast token checks: `relpath.Within` / `relpath.Clean`, the grant's `covers`, diff --git a/docs-developers/server/backups-and-notifications.md b/docs-developers/server/backups-and-notifications.md index 3442f06..2c258f6 100644 --- a/docs-developers/server/backups-and-notifications.md +++ b/docs-developers/server/backups-and-notifications.md @@ -156,7 +156,8 @@ key) or `system`. No IP address is stored. | Area | Actions | |---|---| -| `user` | `create`, `update`, `delete`, `recovery_clear` | +| `user` | `create`, `update` (details gain `can_add_members`, and `managed_by` (the owner's username, or `null`), `member_kind` and `member_access` when an Assign or Make independent changes them), `delete` (details: `members_deleted`, a count, and `members_kept`, usernames), `recovery_clear` | +| `member` | `create` (details: `member`, `kind`, `access`, `sign_in_here`), `update` (`member` plus what changed: `name`, `kind`, `access`, and `collections` as a count), `delete`, `session`, `pair`, `device_revoke` (`device_id`): [household members](api/reference.md#household-members) managed from the player. The actor is the owner, a non-admin, and the target the member's username | | `invite` | `create`, `rotate`, `revoke` | | `library` | `create`, `update`, `reorder`, `folder_override`, `delete` | | `book` | `edit`, `bulk_edit`, `enrichment`, `cover_set`, `cover_remove` | @@ -177,7 +178,7 @@ Deliberately **not** audited: sign-ins and listening (Activity and People > Devices have them), scans, rescans and job cancels (Health > Jobs keeps their history), a community chapter check (it changes nothing an admin chose; picking a book's chapter source is a `book.edit` or `book.bulk_edit`, with -`chapter_source` in its details), and anything a non-admin does. Backup **downloads** are audited +`chapter_source` in its details), and anything else a non-admin does (the `member` area is the one exception: an owner managing their household members). Backup **downloads** are audited (a `GET`; a `HEAD` sends nothing and isn't), since a backup holds every account's hashes. diff --git a/docs-developers/server/configuration.md b/docs-developers/server/configuration.md index 88d3273..cb6246e 100644 --- a/docs-developers/server/configuration.md +++ b/docs-developers/server/configuration.md @@ -163,6 +163,19 @@ restarts. See [Scheduled scans](scanner.md#scheduled-scans) and | `demo.max_users` | int (optional), unset | Cap on concurrent live demo accounts. **Unset → safe default 200** (`config.DefaultDemoMaxUsers`); **explicit `0` = unlimited** (opt-in risk: per-IP creation limits are bypassable by rotating IPs) | | `demo.idle_ttl` | duration string, `"24h"` | Reap demo accounts idle longer than this (background reaper sweeps every 15 min). Must parse as a positive `time.Duration`; empty falls back to 24h | +### Household members (`household.*`) + +Whether people may add household members from the player (player redesign Phase +8b-1; see [Household members](api/reference.md#household-members)), and how many each. +Both are read per request through the API's live config, so a change applies at once. + +| Key | Type / default | Meaning | +|---|---|---| +| `household.members_enabled` | bool, `true` | Anyone with an account but a demo account may add members (`POST /me/household/members`). Off refuses new ones only (`403 feature_off`); existing members keep working and their owners can still manage them. The per-person switch is the admin's `can_add_members` on each account (`PATCH /admin/users/{id}`), not a key here | +| `household.max_members` | int, `6` | Members per person, `1` to `50` (`config.MinMaxMembers`/`MaxMaxMembers`); at it, adding is `409 member_limit`. Outside the range `Validate` refuses the config (a value from the environment included, so the server doesn't start) | + +A `config.yaml` written before the section keeps the defaults. + ### Community metadata (`metadata.*`) The server can enrich a book with a description, production details and its @@ -404,6 +417,8 @@ by the settings table in `internal/config/settings.go` (`fields`: each entry's | `AUDIOSILO_DEMO_LIBRARY` | `demo.library` | string | | `AUDIOSILO_DEMO_MAX_USERS` | `demo.max_users` | integer (`0` = unlimited) | | `AUDIOSILO_DEMO_IDLE_TTL` | `demo.idle_ttl` | Go duration, e.g. `24h` | +| `AUDIOSILO_HOUSEHOLD_MEMBERS_ENABLED` | `household.members_enabled` | `strconv.ParseBool` (`false`/`0`/… turns it off) | +| `AUDIOSILO_HOUSEHOLD_MAX_MEMBERS` | `household.max_members` | integer, 1-50 | | `AUDIOSILO_METADATA_ENABLED` | `metadata.enabled` | `strconv.ParseBool` (`true`/`1`/…) | | `AUDIOSILO_METADATA_BASE_URL` | `metadata.base_url` | URL | | `AUDIOSILO_METADATA_REGION` | `metadata.region` | marketplace code (`uk`) | @@ -463,6 +478,8 @@ restart, and whether the console may change it at all: | `demo.library` | `demo.library` | `AUDIOSILO_DEMO_LIBRARY` | at once | | `demo.max_users` | `demo.max_users` | `AUDIOSILO_DEMO_MAX_USERS` | at once | | `demo.idle_ttl` | `demo.idle_ttl` | `AUDIOSILO_DEMO_IDLE_TTL` | restart | +| `household.members_enabled` | `household.members_enabled` | `AUDIOSILO_HOUSEHOLD_MEMBERS_ENABLED` | at once | +| `household.max_members` | `household.max_members` | `AUDIOSILO_HOUSEHOLD_MAX_MEMBERS` | at once | | `backups.schedule` | `backups.schedule` | `AUDIOSILO_BACKUP_SCHEDULE` | at once | | `backups.keep` | `backups.keep` | `AUDIOSILO_BACKUP_KEEP` | at once (pruning at the next scheduled backup) | | `backups.dir` | `backups.dir` | `AUDIOSILO_BACKUP_DIR` | restart; **read-only** in the console | @@ -535,6 +552,7 @@ reason, and nothing is applied (all or nothing). The handler then writes | `demo.library` | trimmed; must be the name of an existing library | | `demo.max_users` | `null` (the default, 200) or 0 (no limit) to 100000 | | `demo.idle_ttl` | `""` (24h) or a positive Go duration | +| `household.max_members` | an integer from 1 to 50 (`fixMaxMembers`) | | `backups.schedule` | `""`, `daily:HH:MM` or `weekly:DAY:HH:MM` (`backup.ParseSchedule`), stored in canonical form | | `backups.keep` | an integer from 1 to 365 (checked by `Validate`) | diff --git a/docs-developers/server/data-model.md b/docs-developers/server/data-model.md index 4311e47..d853c65 100644 --- a/docs-developers/server/data-model.md +++ b/docs-developers/server/data-model.md @@ -4,7 +4,7 @@ description: "The SQLite schema behind audiosilo-server: the rebuildable index v --- The schema lives in `internal/store/migrations/` as numbered SQL files -(`0001_init.sql` … `0043_cast_tokens.sql`), embedded into the binary and +(`0001_init.sql` … `0044_household_members.sql`), embedded into the binary and applied by `store.Open` at startup. This page documents the **resulting current schema**, noting which migration added what. @@ -123,7 +123,7 @@ joined book should inherit it from its discs). ### Accounts & credentials -**`users`** *(0001; `is_demo` added in 0005)* - `id`, `username` (UNIQUE), +**`users`** *(0001; `is_demo` added in 0005; the household member columns in 0044)* - `id`, `username` (UNIQUE), `password_hash` (argon2id PHC string; **empty string = password-less account**, never a hash of `""`), `role` (`'admin'`/`'user'`), `disabled`, `is_demo`, `created_at`, `updated_at`. `is_demo` flags throwaway demo-mode accounts so the @@ -131,8 +131,22 @@ background reaper deletes by flag, not by username prefix (which could catch a real account named `demo_*`). There is deliberately **no `last_login` column** - last activity is derived from `MAX(tokens.last_seen)`. +The [household member](api/reference.md#household-members) columns *(0044)*: +`managed_by` (FK to `users` `ON DELETE SET NULL`, partial index +`idx_users_managed_by`): the owner who added this member; `member_kind` (`'kid'` / +`'grownup'`, a column CHECK) and `member_access` (`'all'` / `'collections'`, a +column CHECK); `can_add_members` (`INTEGER NOT NULL DEFAULT 1`, the admin's +per-person switch); `display_name` (`TEXT NOT NULL DEFAULT ''`, the name the owner +typed for a member, `''` for everyone else). The first three are **all set or all +NULL**: SQLite can't add a table CHECK with `ALTER TABLE`, so `auth` (`members.go`) +writes and clears them together and a row that says otherwise reads as no member +(`scanUser`). `SET NULL` only covers a member an admin kept when deleting the +owner (`auth.DeleteUser` deletes the others explicitly and clears the kept ones' +other two columns in the same transaction). Durable: this is the account, not the +index. + **`tokens`** *(0001; `auth_code_id` added in 0014; `client_app`, -`client_version`, `client_platform` and `last_ip` in 0018)* - opaque bearer tokens: +`client_version`, `client_platform` and `last_ip` in 0018; `created_on_token_id` in 0044)* - opaque bearer tokens: `user_id` (FK CASCADE), `token_hash` (UNIQUE - only the SHA-256 hash is stored), `kind` (`'session'`/`'pairing'`/`'api'`/`'cast'`: `'api'` a user-minted personal API key whose label rides in `device_name`, `'cast'` *(0043)* a @@ -152,6 +166,12 @@ alone), and `last_ip` the address of its newest request. Both are overwritten by and the daily retention job blanks `last_ip` on revoked tokens (`auth.ForgetRevokedAddresses`), so an address is kept only while the device is signed in. +`created_on_token_id` *(0044; FK to `tokens` `ON DELETE SET NULL`, partial index +`idx_tokens_created_on`)* records, for a household member's session made on their +owner's device (`sign_in_here`, `…/members/{id}/session`), the owner's token it was +made on. It is **information only**, never part of a token's validity: it names the +members [`GET /me/devices`](api/reference.md#get-apiv1medevices) lists under +`member_sign_ins`, and which tokens `DELETE /me/devices/{id}?also_members=1` revokes. The admin console lists live session and API-key tokens as **devices** (never pairing or cast tokens) (`auth.ListDevices`) and can revoke one (`auth.RevokeDevice`). @@ -1006,6 +1026,7 @@ The migration history so far: | 0041 | `series_key` | `books.series_key` and `book_more_series`, a series stored by its key so every series filter is an index seek in any spelling; `idx_books_series_key` replaces `idx_books_series`, and `idx_books_more_series` (0039) goes, since nothing reads it any more. Rows indexed before it are keyed at start by `catalog.KeySeries` (server_state `series_keys`), which also puts every stored `more_series` list through the list rules first | | 0042 | `household_sharing` | `household_sharing`, each person's opt-in to Listening in the house (no row = off) | | 0043 | `cast_tokens` | `tokens.kind` `'cast'` (cast tokens for a Chromecast receiver) and `cast_grants` (each one's book and parent token), with `idx_cast_grants_parent` and the partial `idx_tokens_cast_expiry` | +| 0044 | `household_members` | household members: `users.managed_by` (FK `ON DELETE SET NULL`), `users.member_kind` and `users.member_access` (each with a column CHECK), `users.can_add_members` (`DEFAULT 1`) and `users.display_name` (`DEFAULT ''`), with the partial `idx_users_managed_by`; `tokens.created_on_token_id` (FK `ON DELETE SET NULL`) with the partial `idx_tokens_created_on`. No data moves: every existing account reads as no member, allowed to add members | ## SQLite choices diff --git a/docs-users/admin/console-tour.md b/docs-users/admin/console-tour.md index c3353c1..68a4466 100644 --- a/docs-users/admin/console-tour.md +++ b/docs-users/admin/console-tour.md @@ -213,7 +213,7 @@ The last entry, "Search all books for ...", opens **Server > Settings** holds everything you can change while the server runs, one topic at a time: its name and public address, HTTPS, app links, community -metadata, demo mode, the update check, [backups](backups.md), +metadata, household members, demo mode, the update check, [backups](backups.md), [notifications](notifications.md) and [importing from Audiobookshelf](import.md). Some settings apply at once and some at the next restart; each one says which. See diff --git a/docs-users/admin/server.md b/docs-users/admin/server.md index 25e604d..593a154 100644 --- a/docs-users/admin/server.md +++ b/docs-users/admin/server.md @@ -21,8 +21,8 @@ space, backups - is on [Health > System](health.md#system). **Server > Settings** lists its topics down the left (across the top on a phone): **General**, **Network & HTTPS**, **Players & app links**, -**Community metadata**, **Transcoding**, **Demo mode**, **Backups**, -**Notifications** and **Import**. Each setting lives in exactly one of them. +**Community metadata**, **Transcoding**, **Household**, **Demo mode**, +**Backups**, **Notifications** and **Import**. Each setting lives in exactly one of them. Backups, Notifications and Import have pages of their own: [Backups and restoring](backups.md), [Notifications and the bell](notifications.md) and @@ -313,6 +313,25 @@ into the data folder. To use other copies, start it with the `--ffmpeg` and `--ffprobe` options (see the [configuration reference](/developers/server/configuration#cli-flags-cmdaudiosilo)). +### Household + +The **Household members** card: "People add kids and grown-ups to their +household from the player, with no invite from you. Each member is a real +account, managed by the person who added it." (See +[Household members](users-and-invites.md#household-members).) + +- **People can add household members** - on out of the box. Off stops new + members being added; members already added keep working, and the people who + added them can still manage them. +- **Members per person** - from 1 to 50 (6 unless you change it). Lowering it + never removes anyone: someone already over the new number just can't add + more. + +Both apply at once, with no restart. To stop one person adding members, turn +off **Can add household members** on their Account tab instead. The +`AUDIOSILO_HOUSEHOLD_MEMBERS_ENABLED` and `AUDIOSILO_HOUSEHOLD_MAX_MEMBERS` +variables set them too, and then lock them here. + ### Demo mode The **Demo mode** card runs a public demo from this server (see @@ -429,7 +448,9 @@ app. Things the server did by itself on an admin's behalf, like applying a restore at start, are listed under **AudioSilo**. What is recorded: changes to accounts, invites, devices signed out, shares and -access, people's progress, libraries, books and covers, ignored health issues, +access, household members (added, changed, signed in on a device, given a +pairing code, a device signed out, or deleted, by the person who manages them +in the player, who is named as the one who did it), people's progress, libraries, books and covers, ignored health issues, settings, backups (including every download, restore and cancelled restore), notification destinations, and answers to the [support card](console-tour.md#the-support-card) ("Answered the support card", @@ -442,15 +463,15 @@ What isn't: [notified](notifications.md) of sign-ins). - **Scans** (and rescans of one book, and stopped scans): their history is in [Health > Jobs](health.md#jobs). -- Anything a member does in a player. +- Anything else people do in a player (household members aside). The log never holds a secret: a password change reads only "set" or "removed", an invite never shows its code, and a notification destination's address or secret only as "New address" or "New secret". It doesn't record IP addresses. Entries are kept for a year. -To narrow the list, choose an **Area** (Accounts, Invites, Devices, Shares and -access, Progress, Libraries, Books, Health, Settings, Backups, Notifications), +To narrow the list, choose an **Area** (Accounts, Household members, Invites, +Devices, Shares and access, Progress, Libraries, Books, Health, Settings, Backups, Notifications), a person under **Who** (the current admins), or type in **Search names and paths** (it matches what a change was made to, or who made it). **Show older** at the foot loads the next 50. diff --git a/docs-users/admin/users-and-invites.md b/docs-users/admin/users-and-invites.md index 489f1cc..b8ae8b4 100644 --- a/docs-users/admin/users-and-invites.md +++ b/docs-users/admin/users-and-invites.md @@ -1,6 +1,6 @@ --- title: "People and invites" -description: "Inviting people to an AudioSilo server with a link or QR code, each person's listening, access, devices, sign-in and account, editing someone's progress, signing out a device, rotating and revoking invites, and the safety rails around admin accounts." +description: "Inviting people to an AudioSilo server with a link or QR code, each person's listening, access, devices, sign-in and account, household members (the kids and grown-ups people add from the player), editing someone's progress, signing out a device, rotating and revoking invites, and the safety rails around admin accounts." --- **People** in the [admin console](console-tour.md) is where you invite people, @@ -18,6 +18,9 @@ accounts and how many were active this month. Each card shows: - the person's name, with an **Admin** badge for administrators and a **Demo** badge for visitors' accounts on a server running in [demo mode](../demo.md); +- for a [household member](#household-members), the name their owner gave + them, **Managed by** *owner* and **Kid** or **Grown-up**; for someone who has + added members, how many ("2 household members"); - what they're doing: **Listening now** (one of their devices is playing, or paused in the last ten minutes), **Active** *some time ago*, **Never signed in**, or **Disabled** ("their devices are paused"); @@ -29,7 +32,9 @@ accounts and how many were active this month. Each card shows: "No devices". Personal API keys aren't counted here; the [Devices page](#the-devices-page) lists them. -Click a card to open [that person's page](#a-persons-page). +Click a card to open [that person's page](#a-persons-page). A person's +household members come straight after their card, so a household reads as one +run of cards. ## Inviting someone @@ -124,6 +129,14 @@ Each book in the two lists has a menu (**...**) to What this person can listen to: each **whole library** and each [share](sharing.md) they have, with the folders the share covers. +For a [household member](#household-members), a card above it says where +their books come from, read-only: **Everything Adam can listen to** (with +Adam's libraries; every library when Adam is an admin), or **Collections Adam +shares with them** (each collection with how many books in it Adam can still +reach). Only Adam changes that, from the player. The list under it becomes +**Access given here**: anything you give them directly, on top of the +household. + - **Give access** adds a whole library or one of your shares they don't have yet. - **Remove** takes one away. @@ -162,7 +175,9 @@ and the same **Rotate** and **Revoke** buttons, as the library, and can use this console). Making a password-less member an admin asks you to choose a password for them in the same step ("Make *name* an admin"), since admins sign in to the console with one. You can't change your - own role here; another admin can. + own role here; another admin can. A [household member](#household-members) + can't be made an admin: make them independent first. +- **Household** - the person's place in a household, below. - **Danger zone** - **Disable account** and **Delete account**, below. ### "Last active" @@ -173,6 +188,77 @@ signed in" for an account that has been created but has not connected yet. There is no separate "last login" - a player stays signed in for months, so the last request is the honest measure of activity. +## Household members + +Anyone with an account (except a demo account) can add the people they live +with from the player, on its [Household page](../listening/household.md): a +kid, signed in on their device straight away in kids mode, or a grown-up, who +scans a code. You don't make an invite for them. Each **household member** is +a real account on your server, with their own progress, bookmarks and stats, +**managed by** the person who added them (their **owner**): + +- The owner chooses what the member can listen to: **Everything** the owner + can (following the owner's access as it changes), or **only the collections + the owner shares** with them (and only while the owner can still reach those + books). Anything you give the member directly comes on top. +- The owner renames them, signs their devices out, switches them between kid + and grown-up, and deletes their account, all from the player. +- A **kid** has no password and can't make API keys, pair a device, share a + collection or share their listening with the house. A **grown-up** member can + do all of those, like anyone. +- A member never has members of their own, and is never an admin. +- Disabling an owner doesn't disable their members, and doesn't change what the + members can listen to. + +On their own page, a member's username is made from the name the owner typed +(*evie*, or *evie-adam* when that's taken); the name itself shows beside it. + +### The two settings and the per-person switch + +- **Server > Settings > Household** turns household members on or off for the + whole server (on out of the box) and sets **Members per person** (6 unless + you change it, from 1 to 50): see + [Household](server.md#household). +- **Can add household members**, on a person's **Account** tab, turns it off + for just that person. Off stops them adding anyone new: members they already + added keep working, and they can still manage them. + +### A person's Household card + +On the **Account** tab, the **Household** card shows, for someone who isn't a +member (and isn't a demo account): the **Can add household members** switch, +their household (each member with Kid or Grown-up and what they can listen to, +linking to the member's page), and **Assign to someone**. + +For a household member it shows **Managed by** their owner instead, with: + +- **Make independent** - takes them out of the household, as an ordinary + account you manage here. They keep their progress and anything you gave them + directly, and stop getting books through their owner, who can no longer + manage the account. A kid's account has no password: the devices already + signed in stay signed in, and to add another, [pair a device](#a-persons-page) + for them or set a password. +- **Assign to someone** - moves them to another person's household. + +**Assign to someone** also works for an ordinary account, such as one you made +for a child before household members existed. Pick **Whose household**, **Kid +or grown-up** and **What they can listen to**; the person you pick manages the +account from then on. Their own grants stay. Making someone a kid removes their +password, API keys and unused pairing codes (their signed-in devices stay +signed in); choosing **Only collections** means they see nothing through their +new owner until the owner shares a collection with them. It isn't offered for an +admin, and is greyed out, with the reason, for someone who has household +members of their own. A household member or a demo account can't be the owner. +The owner's own cap and switch don't apply: you decide. + +### Deleting an owner + +Deleting someone who has household members deletes their members too, unless +you say otherwise: the delete dialog lists **Their household members**, each +ticked (**Delete Evie too**). Untick a member to keep them as an independent +account. When it's done, a message says how many members were deleted and how +many kept. + ## Editing someone's progress The progress menu (**...**) sits beside each book on a person's @@ -242,7 +328,9 @@ These are very different levers: it "removes the account and everything tied to it: devices, invites, progress, bookmarks, notes and history. Audio files are not touched. This can't be undone." The confirmation offers **Disable the account instead** if - you want something reversible. + you want something reversible. For someone with household members, it also + lists them, ticked to be deleted too (see + [Deleting an owner](#deleting-an-owner)). When in doubt, disable. Delete is for accounts you are certain you'll never want back. @@ -295,6 +383,7 @@ The server enforces a few guards so you can't lock yourself out: admin. Make someone else an admin first."). - **Admins must keep a password.** A password-less account can only become an admin together with a new password. +- **A household member is never an admin.** Make them independent first. - **You can't delete or disable your own account, or change your own role,** from the console. Ask another admin. - If another admin makes you a member while you're signed in, the console signs diff --git a/docs-users/getting-started/remote-access.md b/docs-users/getting-started/remote-access.md index 9a9381f..d94f632 100644 --- a/docs-users/getting-started/remote-access.md +++ b/docs-users/getting-started/remote-access.md @@ -98,7 +98,7 @@ A request that reaches AudioSilo through a reverse proxy never gives the server Being internet-facing for non-experts is the server's first design priority, and the protections are always on - there is nothing to remember to enable: - **No default passwords.** The admin credential is randomly generated on first run (or chosen by you in the setup wizard); there is no factory password to forget to change. -- **Invite-only accounts.** Nobody can sign themselves up. Accounts are created by the admin, and devices join via short-lived invite codes (by default good for 5 uses and one day). See [People and invites](../admin/users-and-invites.md). +- **No sign-ups.** Nobody can sign themselves up. Accounts are created by the admin, and devices join via short-lived invite codes (by default good for 5 uses and one day). See [People and invites](../admin/users-and-invites.md). The one other way in: someone who already has an account can add the kids and grown-ups they live with as [household members](../listening/household.md), each signed in by them on a device they set up, never through a public page. The admin can turn that off, for everyone or for one person, and caps how many each person adds. - **Rate limiting and lockout.** Requests are rate-limited per visitor address (covers and audio per signed-in device instead, so browsing a large library is never throttled), and repeated failed logins or invite-code guesses trigger a brute-force lockout. - **Secrets are stored scrambled.** Passwords use modern, deliberately slow hashing (argon2id); sign-in tokens and invite codes are stored only as hashes, and every token can be revoked. - **Contained file access.** Streaming requests are strictly confined to your library folders - path-traversal tricks to read other files are rejected - and request sizes are capped. diff --git a/docs-users/index.md b/docs-users/index.md index 5f88ca4..3e5f3bd 100644 --- a/docs-users/index.md +++ b/docs-users/index.md @@ -32,7 +32,7 @@ An optional desktop app for the "librarian" side of things: setting up and conne ## What makes it a good home for your audiobooks -- **Safe to expose to the internet.** No default passwords - credentials are generated on first run or chosen by you in a setup wizard. Accounts are invite-only, log-in attempts are rate limited with brute-force lockout, and HTTPS is on by default. See [Remote access](./getting-started/remote-access.md). +- **Safe to expose to the internet.** No default passwords - credentials are generated on first run or chosen by you in a setup wizard. Nobody can sign themselves up (accounts come from an admin's invite, or from someone with an account adding a member of their household), log-in attempts are rate limited with brute-force lockout, and HTTPS is on by default. See [Remote access](./getting-started/remote-access.md). - **Fast, even with huge libraries.** Search and browsing are built on an indexed database that stays quick at thousands of books, and your library is browsable immediately after adding it - no long import step. - **Your files stay ordinary files.** AudioSilo never converts, moves or rewrites your audiobooks. The folder on disk is the source of truth; the server's database is just a rebuildable index. Rename or move a book and your listening progress follows it. See [Organizing your library](./getting-started/organizing-your-library.md). diff --git a/docs-users/listening/account.md b/docs-users/listening/account.md index ab43f13..31e3e97 100644 --- a/docs-users/listening/account.md +++ b/docs-users/listening/account.md @@ -48,6 +48,8 @@ Every device signed in to your account on this server, **this device** first, th To cut off a phone you've lost or sold, tap **Sign out** on its row and confirm. It loses access straight away and needs to sign in again to reach the server. +If you signed in members of [your household](household.md) on that phone (a kid you added there, say), the confirmation also offers **Also sign out Evie and Ellis on that device**, naming them, ticked. Leave it ticked and they're signed out of that phone too; the message afterwards says how many were. Untick it to sign out only yourself. Their sign-ins on other devices, including their own phones, aren't touched (see [If a phone is lost](household.md#if-a-phone-is-lost)). + **This device** has no Sign out button in the list. To sign this device out, use [Sign out of the server](#signing-out) at the bottom of the page, which saves your place first. The list appears on servers new enough to show it. diff --git a/docs-users/listening/browsing.md b/docs-users/listening/browsing.md index aae20a5..9dd4398 100644 --- a/docs-users/listening/browsing.md +++ b/docs-users/listening/browsing.md @@ -32,7 +32,7 @@ From left to right: - A **search field**. In the web player it opens [quick search](#quick-search-in-the-web-player); in the tablet app it takes you to the Search page. Where the bar is too narrow for the field (a tablet held upright, say), it shrinks to a search button that does the same. - The **Up next** button, with the number of books queued. It opens [Up next](up-next.md). - A **gear** button for [Settings](settings.md). It opens Settings over the page you're on, so going back returns you there. -- Your **profile button** - your initial, plus your name on wider screens. It opens a menu that starts with who's **Listening as**, then the other people on this device (pick one to switch to them; see [Sharing a device with your household](profiles.md)) and **Who's listening?**, then your **servers**, each with a line saying who you're signed in as, or that it's **offline** or **needs signing in again** (pick one to open its [account page](account.md)); **Add a server** (in the apps; the web player only reaches its own server); your [Journal](journal.md); [Settings](settings.md); **Account on** your main server; and a switch between **light and dark** appearance. +- Your **profile button** - your initial, plus your name on wider screens. It opens a menu that starts with who's **Listening as**, then the other people on this device (pick one to switch to them; see [Sharing a device with your household](profiles.md)) and **Who's listening?**, then your **servers**, each with a line saying who you're signed in as, or that it's **offline** or **needs signing in again** (pick one to open its [account page](account.md)); **Add a server** (in the apps; the web player only reaches its own server); your [Journal](journal.md); [Household](household.md); [Settings](settings.md); **Account on** your main server; and a switch between **light and dark** appearance. ![The profile menu open from the top bar](/img/screenshots/web-player/profile-menu.png) diff --git a/docs-users/listening/connecting.md b/docs-users/listening/connecting.md index bb5f752..fcf2f67 100644 --- a/docs-users/listening/connecting.md +++ b/docs-users/listening/connecting.md @@ -73,7 +73,7 @@ An **invite code** comes from your admin, like `9M4K-P2TQ-WX7V-3RHD`. Type it on If you've set a password for your account (see [Your account on a server](account.md#password)), you can also sign in the classic way, on the **Username and password** tab. :::note -Many AudioSilo accounts don't have a password at all - that's normal. Accounts are usually created by invite, and codes and QR pairing cover everyday sign-in. A password is optional and yours to set whenever you like - and it's the most dependable way to sign back in later (see below). +Many AudioSilo accounts don't have a password at all - that's normal. Accounts are usually created by invite (or by someone in your [household](household.md) who added you), and codes and QR pairing cover everyday sign-in. A password is optional and yours to set whenever you like - and it's the most dependable way to sign back in later (see below). ::: ### "Your library is ready." diff --git a/docs-users/listening/household.md b/docs-users/listening/household.md new file mode 100644 index 0000000..861f708 --- /dev/null +++ b/docs-users/listening/household.md @@ -0,0 +1,105 @@ +--- +title: Your household +description: "The Household page: the people on this device, adding a kid or a grown-up to your household with no admin and no invite, what each of them can listen to, their own devices, deleting a member, signing out a lost phone, and Listening in the house." +--- + +The **Household** page is where you look after the people you listen with: who uses this device, the kids and grown-ups you've added to your household on your server, and whether you share what you're listening to with the house. + +## Finding it + +- **On a phone**: the **Me** tab's **Household** section, between Settings and Account. +- **On a tablet or computer**: **Household** in your [profile menu](browsing.md#getting-around) (top right). +- From anywhere: **Manage household** on [Who's listening?](profiles.md#whos-listening), **Household** at the end of the App group in [Settings](settings.md#household), and **Sharing settings** beside Listening in the house on your Home page. In the web player's quick search, **Go to** lists it too. + +The page has three parts: + +- **On this device**: everyone who listens on this phone, tablet or computer, with their name, PIN and kids mode (see [Sharing a device with your household](profiles.md#on-the-household-page)). +- **Your household**: the people you've added to your household on your server, one card each, and **Add someone to your household**. With more than one server, each one that offers it has its own section, *Your household on Hearthside*. +- **Listening in the house**: the **Share what I'm listening to with the house** switch, one per server (see [Listening in the house](in-the-house.md#turning-it-on)). + +## Household members + +A **household member** is someone you add yourself, from the player: a kid, or another grown-up you live with. You don't need the server's admin, an invite or an email address. Each member gets **their own account** on your server, so they have their own place in every book, their own Up next, bookmarks and listening stats, and nothing they do moves yours. + +You manage the people you add: you choose what they can listen to, rename them, sign their devices out and delete their account. They can't add people of their own, and a member is never an admin. + +The **Your household** section shows how many you've added, *"2 of 6 added"*, against the most the server allows. If you can't add anyone, it says why: + +- *"Your server's admin has turned off adding people."*: the admin turned household members off for the whole server. +- *"Your server's admin hasn't let this account add people."*: the admin turned it off for your account. +- *"Only the person who manages your account can add people."*: you are someone's household member yourself. +- *"Demo accounts can't add people."* +- *"You've added the most people this server allows (6)."* + +Even when adding is turned off, you can still rename, sign out and delete the people you added before. On a server running an older AudioSilo, the section isn't there at all. + +## Adding a kid + +1. On the Household page, tap **Add someone to your household**. +2. Type their **Name** (up to 40 characters). Their username on the server is made from it (*evie*, or *evie-adam* if that's taken), and you can rename them later. +3. Leave **Kids account** on: *"No password. Signed in on this device straight away, in kids mode."* +4. Under **What can they listen to?**, a kid starts on **Only collections I share** (see [What they can listen to](#what-they-can-listen-to)). Tick the collections to share with them, or tap **Create Evie's books** to make a new, empty one for them. +5. Tap **Add and sign in here**. + +Evie is now a person on this device, in [kids mode](kids-mode.md), with the default bedtime. You stay the person listening, and whatever was playing carries on: choose Evie on Who's listening? when it's their turn. + +If your own profile on this device has no [PIN](profiles.md#a-pin-for-a-person), the app offers to set one (*"Set a PIN for Adam, so Evie can't switch to you without it."*). Take it: without a PIN, a child who leaves kids mode can choose you on Who's listening?. + +A kids account has **no password**. It can't set one, create [API keys](api-keys.md), pair another device, share collections, or show up in (or see) [Listening in the house](in-the-house.md). It signs in only on devices you set up for it: this one, or its own device with [Set up their own device](#their-devices). + +## Adding a grown-up + +1. Tap **Add someone to your household** and type their name. +2. Turn **Kids account** off. **What can they listen to?** then starts on **Everything I can listen to**. +3. Tap **Add and show their code**. + +The app shows **Show Ellis this code**: a QR code to scan with the AudioSilo app on their own phone, or a link to open. It works once, for 10 minutes. If they'll listen on your device too, tap **They'll also use this phone** (or **They'll also use this device**) to sign them in here as well, as a person of their own. + +A grown-up member is an ordinary account in every other way: they can set a password, pair their other devices, create API keys, share collections and share their listening with the house. + +The defaults follow the **Kids account** switch (a kid on Only collections I share, a grown-up on Everything I can listen to) until you pick one yourself. Either choice works for either kind of person. + +## What they can listen to + +- **Everything I can listen to**: whatever you can listen to on that server, now and later. A library the admin gives you later is theirs too, and if you lose a library, so do they. If you're an admin, that's every library. +- **Only collections I share**: just the books in the [collections](browsing.md#collections) you share with them. Add a book to one of those collections and they can play it straight away; take it out, or stop sharing the collection, and it's gone for them just as quickly. They only ever get books you can still listen to yourself. + +On top of either, a member keeps anything the server's admin gave their account directly. + +To change it, open their card and tap **Change** beside **What they can listen to**. When you share a collection with a member who is on Only collections I share, the share sheet says so: *"Evie can listen to everything in this collection."* + +For a kid, the shared collections are what [kids mode](kids-mode.md#what-the-child-sees) shows. + +## A member's card + +Each person you've added has a card on the Household page: their name, *Kid* or *Grown-up* with their username, and **On this device** when they're signed in here. On it: + +- **What they can listen to**, with each shared collection (tap one to open it) and **Change**. +- **Kids account**: switch it on to make a grown-up a kid. The app asks first (*"Make Ellis a kids account?"*): their password and API keys are removed, and the devices they're signed in on stay signed in. Switching it off makes a kid a grown-up, who can then set a password. +- **Their devices** (below). +- **Name**, with **Rename**: the name everyone on the server sees. Their username stays the same. +- **Delete their account** (below), and **Remove from this device** when they're signed in here. + +## Their devices + +The card lists every device the member is signed in on, each with **Sign out**. Signing one out means they can't listen there until you set it up again. + +- **Set up their own device** shows a code for their own phone or tablet: they scan it with the AudioSilo app, and it signs straight in. On a kid's own device the app starts in [kids mode](kids-mode.md#on-a-kids-own-device), even though no grown-up is signed in there. +- **Sign in on this device** signs them in here too, when they aren't already. + +## Deleting a member, or removing them from this device + +These are different: + +- **Remove from this device** signs them out of your server on this device and deletes their downloads here. Their account, and their place in every book, stay on the server, and **Sign in on this device** on their card brings them back. It's the same as removing anyone else from the device (see [Sharing a device with your household](profiles.md#on-the-household-page)). +- **Delete their account** deletes the account itself, on the server, with their listening history, bookmarks and notes. It can't be undone, so the app asks twice: once to **Continue**, then you type their name and tap **Delete account**. They stay on this device as a person until you remove them from it too. + +## If a phone is lost + +Kids and grown-ups you signed in **on your own phone** go with it. To cut them off along with you, open your [account page](account.md#signed-in-devices) on another device, find the lost phone under **Signed-in devices** and tap **Sign out**. When household members of yours are signed in on that phone, the confirmation adds **Also sign out Evie and Ellis on that device**, ticked: confirm, and they're signed out there too. + +Untick it to sign out only yourself: their sign-ins on that phone stay, which is what you want for a family tablet that stays at home. Either way, the devices they set up themselves with their own code aren't touched; sign those out from their card. + +## What the admin sees + +Members are real accounts, so they appear in the admin console with the person who manages them. The admin can turn household members off for everyone or for one person, change how many each person may add (6 unless changed), and move or free a member: see [Household members](../admin/users-and-invites.md#household-members) in the admin guide. diff --git a/docs-users/listening/in-the-car.md b/docs-users/listening/in-the-car.md index 7971e88..eb4f3c1 100644 --- a/docs-users/listening/in-the-car.md +++ b/docs-users/listening/in-the-car.md @@ -39,6 +39,10 @@ Each book shows its cover, its author and, once you've started it, the time left When several people share the phone ([profiles](profiles.md)), the car shows only the books of the person listening now, and switching person changes the lists. The lists follow what you do on the phone: start a book, pause, add to your queue or finish a download, and the car catches up within a few seconds. If none of your servers can be reached when the car connects, the car keeps the lists it showed last time, and your downloaded books still play. The car's words are in the language you chose for the app. +### A kids profile in the car + +When the person listening is in [kids mode](kids-mode.md), the car shows only their stories: **Your stories**, the same stories as the kids screen, and **Continue listening** for the ones they're part-way through. There's no Up next or Downloads tab, and nothing else their account could reach. Switching to a grown-up, or turning kids mode off, changes the lists straight away. + ## Playing a book Tap a book to play it from your saved place; tap the book that's already playing and it simply carries on. It plays through your phone's AudioSilo app, with the same speed, auto-rewind and [Smart speed and Voice boost](full-player.md#smart-speed-and-voice-boost) settings as on the phone. Connecting the car doesn't start a book by itself, but if your car (or Android Auto's setting to start music automatically) presses play when you connect, that works like the car's play button. diff --git a/docs-users/listening/in-the-house.md b/docs-users/listening/in-the-house.md index c7d80dd..8115ef2 100644 --- a/docs-users/listening/in-the-house.md +++ b/docs-users/listening/in-the-house.md @@ -3,13 +3,13 @@ title: Listening in the house description: "See what the other people on your server are listening to, on your Home page and on a book's page: off by default, only between people who both share, and what is and isn't shared." --- -**Listening in the house** shows what the other people on your server are listening to: who, which book, which chapter, and whether they're listening right now. It's for a household sharing one AudioSilo server, so you can see that your partner is halfway through the book you just finished, or pick up the one the kids are on. +**Listening in the house** shows what the other people on your server are listening to: who, which book, which chapter, and whether they're listening right now. It's for a household sharing one AudioSilo server, so you can see that your partner is halfway through the book you just finished, or see which book a housemate started last night. It is **off by default**, and it only works **both ways**: you see the others only while you share yours too, and they see you only while they share theirs. ## Turning it on -Open [Settings](settings.md) and go to **Household and sharing**. Each of your servers that supports it has a switch: **Share what I'm listening to with the house**. Under it, the app says exactly what it does: +Open the [Household page](household.md) (**Sharing settings** beside the Home page's row opens it too). Under **Listening in the house**, each of your servers that supports it has a switch: **Share what I'm listening to with the house**. Under it, the app says exactly what it does: *"Your current book shows on the Home page of other people on Hearthside who share theirs too. Off by default."* @@ -41,6 +41,7 @@ The house is checked again every minute while the app is open on the Home page o - Only their most recent book, and only if they listened to it in the last 24 hours. - Only books **you** can open yourself. If someone's latest book is in a library or folder you don't have access to, that person doesn't appear at all, rather than showing an older book. - Never a demo account, and never an account the admin has disabled. +- Never a kids account from someone's [household](household.md): a kid can't turn sharing on, never shows up for others and never sees the house. ## What is and isn't shared diff --git a/docs-users/listening/kids-mode.md b/docs-users/listening/kids-mode.md index ce1fc25..e8a8de9 100644 --- a/docs-users/listening/kids-mode.md +++ b/docs-users/listening/kids-mode.md @@ -1,23 +1,37 @@ --- title: Kids mode -description: "A simple AudioSilo for children: one library as big covers, a big player, a bedtime that starts a sleep timer, and a Grown-ups button you hold to leave. Setting it up for a child's profile, and choosing what they can see." +description: "A simple AudioSilo for children: their stories as big covers, a big player, a bedtime that starts a sleep timer, and a Grown-ups button you hold to leave. Adding a kid to your household, setting kids mode up for any profile, a kid's own device, and choosing what they can see." --- **Kids mode** turns AudioSilo into something a child can use on their own: their stories as big covers, a big simple player, and a bedtime. There's no search, no settings, no downloads page and nothing else to wander into. It works in the iPhone and Android apps and in the web player. ## Setting it up -Kids mode belongs to a [profile](profiles.md): a person on this device. To set one up: +Kids mode belongs to a [profile](profiles.md): a person on this device. -1. **Give the child an account on your server**, and limit what it can see to their books: in the admin console, a [share](../admin/sharing.md) with just the children's library or folders, given to that account (see [Users and invites](../admin/users-and-invites.md)). What the account can open is what kids mode can show: the app never shows a child more than their account is allowed. -2. **Add the child to the device**: **Add someone** on [Who's listening?](profiles.md#adding-someone), signing in with the child's account. The app then switches to the child. -3. **Switch back to yourself** (on Who's listening?, or **Switch** on your card in Settings > Household and sharing), then **turn on Kids mode** for the child in [Settings > Household and sharing](settings.md#household-and-sharing), under their name, and pick: - - **Library**: the one library kids mode shows, when the child's account can see more than one. Left alone, it's the first one the account lists. - - **Bedtime**: when bedtime starts, **7:30 PM** unless you change it (times are written the way the app's language writes them: 7:30 PM in English, 19:30 in the other languages). +### The quick way: add a kid to your household -If you turn kids mode on while the child is the one listening, the app goes straight into kids mode. To pick the library and bedtime then, hold **Grown-ups**, choose yourself and open Settings > Household and sharing. +On a server that offers it, open the [Household page](household.md), tap **Add someone to your household**, type the child's name and leave **Kids account** on (see [Adding a kid](household.md#adding-a-kid)). The child gets an account of their own on your server, made by you with no admin, and is signed in on this device straight away, already in kids mode with the default bedtime. You stay the person listening. -Kids mode can't be turned on for the last grown-up on the device: someone has to be able to leave it. +They hear what you share with them: **Only collections I share** (the default for a kid) gives them just the books in the collections you choose, and kids mode shows those collections as their stories. Add a book to one of those collections and it's on their shelf at once. + +### Kids mode for any profile + +Kids mode also works for a child who signs in with an account the server's admin made, limited to their books with a [share](../admin/sharing.md) (see [People and invites](../admin/users-and-invites.md)). What the account can open is what kids mode can show: the app never shows a child more than their account is allowed. + +1. **Add the child to the device**: **Add someone** on [Who's listening?](profiles.md#adding-someone), signing in with the child's account. The app then switches to the child. +2. **Switch back to yourself** (on Who's listening?, or **Switch** on your card on the Household page), then **turn on Kids mode** for the child under their name in **On this device** on the [Household page](household.md). + +### The library and bedtime + +Under the child's name on the Household page, with **Kids mode** on, pick: + +- **Library**: the one library kids mode shows, when the child's account can see more than one and you share no collections with them. Left alone, it's the first one the account lists. +- **Bedtime**: when bedtime starts, **7:30 PM** unless you change it (times are written the way the app's language writes them: 7:30 PM in English, 19:30 in the other languages). + +If you turn kids mode on while the child is the one listening, the app goes straight into kids mode. To pick the library and bedtime then, hold **Grown-ups**, choose yourself and open the Household page. + +Kids mode can't be turned on for the last grown-up on the device: someone has to be able to leave it. A kids account from your household is the exception, on its own device (below). It's a good idea to give the grown-ups' profiles a [PIN](profiles.md#a-pin-for-a-person): leaving kids mode goes through Who's listening?, and without a PIN a child can choose a grown-up there. @@ -28,10 +42,12 @@ It's a good idea to give the grown-ups' profiles a [PIN](profiles.md#a-pin-for-a - **"Hi Leo"** at the top, with the **Grown-ups** button. - **The bedtime card**: *"Bedtime is at 7:30 PM"*, and what happens then. At bedtime it says *"It's bedtime"*, and while the sleep timer runs, how many minutes it has left. - **The story playing now**, in a big player: the cover, the title, the chapter and the minutes left in it, a line showing how far through the chapter they are, and four big buttons: **previous chapter**, **go back 30 seconds**, **play/pause** and **next chapter**. No speed, no sleep menu, no bookmarks. -- **Your stories**: the library as big cover tiles, the stories they're part-way through first, then every other book, finished ones too (children listen again). Tap one and it plays right there. +- **Your stories**: big cover tiles, the stories they're part-way through first, then every other book, finished ones too (children listen again). Tap one and it plays right there. For a kid in your household, the stories are the books in the collections you share with them, grouped under each collection's name when there are several; while those collections have no stories yet, it says *"Ask a grown-up to add stories."* Anyone else, and a kid you haven't shared a collection with yet, sees the one library chosen for them (if their account can see one). ![Kids mode on a phone: the bedtime card, the four big player buttons and the story tiles in two columns](/img/screenshots/web-player/phone-kids.png) +In the car, a kids profile's [CarPlay or Android Auto](in-the-car.md#a-kids-profile-in-the-car) shows only these stories. + If the server can't be reached, kids mode shows the stories downloaded to this device for the child's account, so a downloaded story still plays in the car or on a plane. They come from the child's library when the app knows which one that is: the **Library** you picked, or the one it was showing before the server went away. If the app is opened while the server can't be reached and no Library was picked, every story downloaded for the child's account shows. With no downloads, it says the stories can't load and offers **Try again**. If the child's account needs signing in again (for example after the server was reset), a banner on the kids screen says so. Signing in again there keeps the child's profile and kids mode. @@ -49,4 +65,10 @@ From the bedtime you set until **6:00 AM** the next morning, starting or resumin Holding it opens [Who's listening?](profiles.md#whos-listening): choose a grown-up (with their PIN, if they have one) to leave kids mode, or the child again to go back. While a child is listening and a grown-up is on the device, **Add someone** is turned off there ("Choose a grown-up before adding someone."), and so is signing in to a new server or the demo, so a child can't get out of kids mode by adding a new person. Choose a grown-up first. On Android, the back button never takes a child out of kids mode (at most it closes the app). -To stop using kids mode for good, turn **Kids mode** off for that person in Settings > Household and sharing. +To stop using kids mode for good, turn **Kids mode** off for that person on the [Household page](household.md). That only changes what this device shows: what the account may listen to is the server's to decide, so turning kids mode off never lets a child hear more. + +## On a kid's own device + +A kid in your household can have a phone or tablet of their own: on their card on the Household page, tap **Set up their own device** and scan the code with the AudioSilo app on their device (see [Their devices](household.md#their-devices)). The app there starts in kids mode by itself, even though no grown-up is signed in on it. + +On that device, holding **Grown-ups** opens Who's listening? with only the child on it, and **Add someone** stays available there: a grown-up can sign in with their own account to change the bedtime or turn kids mode off, and then remove themselves from the device again. A kids account can't set a password, make API keys, pair another device or share its listening with the house, whichever device it's on. diff --git a/docs-users/listening/profiles.md b/docs-users/listening/profiles.md index ea06a08..d306272 100644 --- a/docs-users/listening/profiles.md +++ b/docs-users/listening/profiles.md @@ -1,6 +1,6 @@ --- title: Sharing a device with your household -description: "Profiles: everyone who listens on one phone, tablet or computer keeps their own place in every book. Who's listening?, Add someone, switching, the optional PIN, removing someone, and links that belong to someone else." +description: "Profiles: everyone who listens on one phone, tablet or computer keeps their own place in every book. Who's listening?, adding someone to the device, switching, the optional PIN, the Household page's On this device, removing someone, and links that belong to someone else." --- A family tablet, a phone that gets handed round in the car, the computer in the kitchen: when more than one person listens on the same device, each of them can have a **profile** of their own. A profile is a person on this device. Each person signs in with their own AudioSilo account, so each keeps their own place in every book, their own [Up next](up-next.md), [bookmarks](bookmarks-and-notes.md) and [listening stats](you.md). The app shows one person's books and servers at a time: whoever is listening now. @@ -11,19 +11,23 @@ If you were already using AudioSilo when profiles arrived, nothing changes until **Who's listening?** shows everyone on this device as a large initial in a coloured circle, with a line under each name. The person listening now has a pink ring. Tap someone to switch to them, or **Close** to carry on as you are. -![Who's listening? on a computer: Ellen with the pink ring, Jamie with a key for the PIN, Leo in kids mode with his bedtime, and Add someone](/img/screenshots/web-player/profiles.png) +![Who's listening? on a computer: Ellen with the pink ring, Jamie with a key for the PIN, Leo in kids mode with their bedtime, and Add someone](/img/screenshots/web-player/profiles.png) Open it from: - **on a phone**, the **Me** tab's **Account** section, under *Listening as*; - **on a tablet or computer**, your [profile menu](browsing.md#getting-around) (top right), which also lists the other people to switch to straight away; -- [Settings > Household and sharing](settings.md#household-and-sharing). +- the [Household page](household.md)'s **On this device**. -With two or more people on the device it also opens **when the app starts**, so whoever picks it up chooses first. Turn that off with **Ask who's listening when the app opens** in Settings > Household and sharing. It's turned on for you when a second person is added. +With two or more people on the device it also opens **when the app starts**, so whoever picks it up chooses first. Turn that off with **Ask who's listening when the app opens** on the Household page. It's turned on for you when a second person is added. + +**Manage household** at the foot of Who's listening? opens the [Household page](household.md) (not while someone must be chosen, and not from kids mode). ## Adding someone -Tap **Add someone** on Who's listening? (or in Settings > Household and sharing). They sign in with **their own account**, on your server or on another one, exactly as when [connecting a server](connecting.md). They become a new person on this device, and the app switches to them. +Someone who **doesn't have an account yet** (a child, or a partner who has never used AudioSilo) is easiest to add from the [Household page](household.md#household-members): **Add someone to your household** makes their account on your server, with no admin and no invite, and signs a kid in on this device straight away. + +Someone who **already has an account**: tap **Add someone** on Who's listening?, or **Sign in someone who already has an account** on the Household page. They sign in with **their own account**, on your server or on another one, exactly as when [connecting a server](connecting.md). They become a new person on this device, and the app switches to them. Each person can have one account on each server. If you sign in to a server where you already have an account, as a different user (a child's account on the family server, say), that account becomes a new person too, rather than replacing yours. @@ -39,27 +43,29 @@ Nothing of the other person is lost: their downloads stay on the device, and lis ## A PIN for a person -Each person can have a **PIN**: four digits, asked whenever someone switches **to** that person. It's off by default. Set, change or remove it in Settings > Household and sharing, under that person's **PIN**. Changing or removing a PIN asks for the current one first. +Each person can have a **PIN**: four digits, asked whenever someone switches **to** that person. It's off by default. Set, change or remove it on the [Household page](household.md), under that person's **PIN**. Changing or removing a PIN asks for the current one first. - A wrong PIN says *"That PIN doesn't match"*. After five wrong tries in a row, the prompt makes you wait 30 seconds. - Leaving [kids mode](kids-mode.md) goes through Who's listening?, so whoever is chosen there is asked for their PIN. - A PIN stays on this device: it isn't your account's password, and the server never sees it. It keeps a person's place and books away from a sibling's taps; it isn't a lock against someone determined to get in. -**Forgotten a PIN?** Remove that person from this device in Settings > Household and sharing (removing someone doesn't ask for their PIN), then sign in to their account again with **Add someone**. Their place in every book is on the server, so nothing is lost. +**Forgotten a PIN?** Remove that person from this device on the Household page (removing someone doesn't ask for their PIN), then sign in to their account again with **Add someone** (or, for a member of your household, **Sign in on this device** on their card). Their place in every book is on the server, so nothing is lost. -## Settings > Household and sharing +## On the Household page -![Settings, Household and sharing on a computer: Leo's card with Switch, Name, PIN, Kids mode, Library and Bedtime, then Ask who's listening when the app opens and the Listening in the house switch](/img/screenshots/web-player/settings-household.png) +![A person's card on this device: Leo's card with Switch, Name, PIN, Kids mode, Library and Bedtime, then Ask who's listening when the app opens and the Listening in the house switch](/img/screenshots/web-player/settings-household.png) -[Settings](settings.md#household-and-sharing) lists everyone on this device. For each person: +**On this device**, at the top of the [Household page](household.md), lists everyone on this device. For each person: - **Switch** to them (the person listening now is marked *Listening now*); - **Name**: rename them. The name shows on this device only; their account keeps its username; - **PIN**: set, change or remove it (above); - **Kids mode**, with its library and bedtime (see [Kids mode](kids-mode.md)); -- **Remove from this device**: signs them out of their servers on this device and deletes their downloads here. Their place in every book stays on the server. If they have no password on a server, the app warns you first: they'll need a new invite to sign in there again. +- **Remove from this device**: signs them out of their servers on this device and deletes their downloads here. Their place in every book stays on the server. If they have no password on a server, the app warns you first: they'll need a new invite to sign in there again (a member of your household needs no invite: **Sign in on this device** on their card signs them in again). + +Below the people: **Who's listening?**, **Sign in someone who already has an account**, and the **Ask who's listening when the app opens** switch. Further down the page come the people you've added to [your household](household.md#household-members) and [Listening in the house](in-the-house.md#turning-it-on), one switch per server of the person listening. -Below the people: **Who's listening?**, **Add someone**, the **Ask who's listening when the app opens** switch, and [Listening in the house](in-the-house.md#turning-it-on), one switch per server of the person listening. +Removing someone from this device never deletes their account. For a member of your household, **Delete their account** on their card does that: see [Deleting a member](household.md#deleting-a-member-or-removing-them-from-this-device). ## When the person listening is removed diff --git a/docs-users/listening/settings.md b/docs-users/listening/settings.md index d07c986..d57cf67 100644 --- a/docs-users/listening/settings.md +++ b/docs-users/listening/settings.md @@ -10,7 +10,7 @@ description: "Every setting in the player, each in one place: playback, the slee The settings come in three groups: - **Listening**: [Playback](#playback), [Sleep](#sleep) and [Up next and downloads](#up-next-and-downloads). -- **App**: [Appearance](#appearance), [Language](#language) and [Household and sharing](#household-and-sharing). +- **App**: [Appearance](#appearance) and [Language](#language), then a link to the [Household](#household) page. - **Servers**: [Accounts and devices](#accounts-and-devices) and, on the web and Android, [Support AudioSilo](#support-audiosilo). On a wide screen the groups and their sections run down the left and one section shows beside them; pick another to switch. On a phone, or a narrow window, every section is on one page, one under the other under its group's name. @@ -85,19 +85,9 @@ On a tablet or computer, the profile menu also has a quick switch between light The app speaks **English, Español, Français, Deutsch, Português, and Italiano**. Pick one, or leave it on **System** to follow your device's language. -## Household and sharing +## Household -![Household and sharing on a computer: a kids profile's card with its Library and Bedtime, Ask who's listening when the app opens, and Share what I'm listening to with the house](/img/screenshots/web-player/settings-household.png) - -Everyone who listens on this device, each with their own servers, place in every book, queue and stats (see [Sharing a device with your household](profiles.md)). Each person has a card: - -- **Switch** to them, or *Listening now* for the person listening; -- **Name** - rename them on this device (their account keeps its username); -- **PIN** - **Set a PIN**, or **Change** and **Remove** it (see [A PIN for a person](profiles.md#a-pin-for-a-person)); -- **Kids mode** - turns this person's app into [kids mode](kids-mode.md), with the **Library** it shows and the **Bedtime** (from then until 6:00 AM, playing starts a 20-minute sleep timer); -- **Remove from this device** - signs them out of their servers here and deletes their downloads here. Their place in every book stays on the server. It doesn't ask for their PIN: it's the way out of a forgotten one. - -Under the people: **Who's listening?** and **Add someone**; **Ask who's listening when the app opens** (with two or more people on the device); and **Listening in the house**, with a **Share what I'm listening to with the house** switch for each server of the person listening that supports it (see [Listening in the house](in-the-house.md#turning-it-on)). +The App group ends with **Household**, which opens the [Household page](household.md): the people on this device with their names, PINs and kids mode, the people you've added to your household on your server, and Listening in the house. It used to be a section of Settings; an old link to it opens the Household page. ## Accounts and devices @@ -116,5 +106,5 @@ On the web and Android, **Support AudioSilo** links to GitHub Sponsors - AudioSi Settings shows the version of the app you're running, e.g. *AudioSilo v1.1.1*: under the sections on a wide screen, at the foot of the page on a phone. Handy to mention if you ever report a problem (see [Troubleshooting](../troubleshooting.md)). The version of a server is at the foot of your [account page](account.md) for it. :::note -Things an admin manages - creating accounts, invites, what libraries you can see - aren't in Settings; they live in the server's admin console. See [People and invites](../admin/users-and-invites.md). +Things an admin manages - invites, what libraries you can see - aren't in Settings; they live in the server's admin console. See [People and invites](../admin/users-and-invites.md). Accounts for the kids and grown-ups you live with you can make yourself, on the [Household page](household.md#household-members). ::: diff --git a/docs-users/listening/you.md b/docs-users/listening/you.md index 2f4cbe1..ace94e2 100644 --- a/docs-users/listening/you.md +++ b/docs-users/listening/you.md @@ -3,15 +3,15 @@ title: You and your listening description: "The You destination (the Me tab on a phone): Your listening, with this week, your streak, your yearly goal, the listening calendar and clock, hours per week, your top authors, narrators and series, and the books you finished this year." --- -**You** is the part of AudioSilo about you rather than your library: how much you've listened, your year as a story, your [Journal](journal.md), your [settings](settings.md) and your [account](account.md). +**You** is the part of AudioSilo about you rather than your library: how much you've listened, your year as a story, your [Journal](journal.md), your [settings](settings.md), your [household](household.md) and your [account](account.md). ## Finding You -**On a phone**, it's the **Me** tab at the bottom of the screen. A row of sections runs under its title: **Stats**, **Year**, **Journal**, **Settings** and **Account**. Swipe the row to see them all, and tap one to switch. The big title says where you are: *Your listening* over Stats, *Year in listening* over Year, and so on. +**On a phone**, it's the **Me** tab at the bottom of the screen. A row of sections runs under its title: **Stats**, **Year**, **Journal**, **Settings**, **Household** and **Account**. Swipe the row to see them all, and tap one to switch. The big title says where you are: *Your listening* over Stats, *Year in listening* over Year, and so on. -**On a tablet or computer**, it's **You** in the [top bar](browsing.md#getting-around). Its sections are **Stats**, **Year in listening** and **Journal**. Settings is the gear beside your profile button, and your account is in the profile menu. +**On a tablet or computer**, it's **You** in the [top bar](browsing.md#getting-around). Its sections are **Stats**, **Year in listening** and **Journal**. Settings is the gear beside your profile button, and the [Household page](household.md) and your account are in the profile menu. -In the web player's [quick search](browsing.md#quick-search-in-the-web-player), **Go to** lists **Your listening**, **Year in listening**, **Journal** and **Settings**. +In the web player's [quick search](browsing.md#quick-search-in-the-web-player), **Go to** lists **Your listening**, **Year in listening**, **Journal**, **Settings** and **Household**. ## Your listening diff --git a/sidebars-users.ts b/sidebars-users.ts index 0e1ee43..c589a86 100644 --- a/sidebars-users.ts +++ b/sidebars-users.ts @@ -55,6 +55,7 @@ const sidebars: SidebarsConfig = { 'listening/mobile-apps', 'listening/in-the-car', 'listening/chromecast', + 'listening/household', 'listening/profiles', 'listening/kids-mode', 'listening/in-the-house', From 16b13c64283c9d43bc920ad5875bd0921fd6f865 Mon Sep 17 00:00:00 2001 From: KodeStar Date: Tue, 13 Oct 2026 17:53:54 +0100 Subject: [PATCH 02/11] Household members, pass 2: the docs follow the final server Reference: the member routes' guards in the order they answer, the uncapped username numbering (no username_taken on add), 409 member_disabled on a member's session and pairing code, every route's status table, the full kid switch (invites, house sharing, collections shared outside the household); display_name on a member's /me and session envelopes; the admin's managed_by.display_name, members[] with created_at and devices, owner_libraries and owner_is_admin, the create answering the admin object; PATCH as one transaction with kid_no_password, member_is_demo and the moves (leaveOwners); keep_members repeated or comma-separated; also_members audited; the settings envelope's household facts. Error codes, auth and security (the rule riding on the request, UpdateUser's guards, kids refused inside the writes, disabled members, moves), configuration, the data model, the audit log and the cross-repo contract follow. User Guide: People's household groups, a kid's Password card, what Assign, Make independent and keeping a member on delete sign out, the full kid switch, Members per person's range check, the book page's access line, and a disabled member on the Household page. --- .../architecture/cross-repo-contract.md | 25 +- docs-developers/server/api/index.md | 9 +- docs-developers/server/api/reference.md | 282 ++++++++++++------ docs-developers/server/auth-and-security.md | 164 ++++++---- .../server/backups-and-notifications.md | 4 +- docs-developers/server/configuration.md | 8 +- docs-developers/server/data-model.md | 11 +- docs-users/admin/books.md | 5 +- docs-users/admin/server.md | 10 +- docs-users/admin/users-and-invites.md | 49 ++- docs-users/listening/household.md | 4 +- 11 files changed, 393 insertions(+), 178 deletions(-) diff --git a/docs-developers/architecture/cross-repo-contract.md b/docs-developers/architecture/cross-repo-contract.md index 4b2307c..8685967 100644 --- a/docs-developers/architecture/cross-repo-contract.md +++ b/docs-developers/architecture/cross-repo-contract.md @@ -572,23 +572,30 @@ player, and everything about what it may do is the server's. `AddMemberResult`, `UpdateMemberBody`, `MemberKind`, `MemberAccess`), `client.ts` and `hooks.ts` ([State and data](../frontend/state-and-data.md#household-members-player-redesign-phase-8b-1)). - The `user` object (`/me` and every session envelope) gains `managed_by` (`{ id, - username }` or `null`) and `member_kind` (`"kid"`, `"grownup"` or `null`). The - member's access is not on it: the player can't see it and never needs it. + username }` or `null`) and `member_kind` (`"kid"`, `"grownup"` or `null`), and, for a + member only, `display_name` (the name the owner typed; the player names a new profile + for the account after it). The member's access is not on it: the player can't see it + and never needs it. - `/me/devices` rows gain `member_sign_ins`, and `DELETE /me/devices/{id}?also_members=1` answers `{ current, members_signed_out }`. - The admin's user objects gain `member_access`, `can_add_members`, `members_count` - and `display_name`; `PATCH /admin/users/{id}` takes `can_add_members` and - `managed_by`; `DELETE /admin/users/{id}?keep_members=`. The settings envelope gains - `household` (`members_enabled`, `max_members`). These are the console's, inside the - server repo. + and `display_name`, and their `managed_by` the owner's own `display_name`; a person's + detail gains `members` and `member_collections`, and for a member `owner_libraries` + and `owner_is_admin`; `PATCH /admin/users/{id}` takes `can_add_members` and + `managed_by` (one transaction); `DELETE /admin/users/{id}?keep_members=`. The settings + envelope gains `household` (`members_enabled`, `max_members`, and the read-only + `max_members_default`, `max_members_min`, `max_members_max`). These are the console's, + inside the server repo. **Server rules** (each with an allowed and a denied test; [Auth & security](../server/auth-and-security.md#household-members)): the member rule -in `UserScope`/`UserScopes`/`AccessibleLibraries` (the owner's current access for +read with the request's token (`CallerScope`/`CallerScopes`/`CallerLibraries`; by id, +`UserScope`/`UserScopes`/`AccessibleLibraries`) (the owner's current access for `all`, the owner's shared collections intersected with it for `collections`, plus the member's own grants), one level and never admin, the kid refusals -(`403 kid_account`), kids out of share targets and Listening in the house, someone -else's member always `404`. +(`403 kid_account`, at the route and inside the minting writes), kids out of share +targets and Listening in the house, someone else's member always `404`, a disabled +member's session and pairing code `409 member_disabled`. **Client rules:** a member signed in on the owner's device is placed by `addMemberHere` (the owner stays the person listening, the default connection diff --git a/docs-developers/server/api/index.md b/docs-developers/server/api/index.md index bb664d1..01449a1 100644 --- a/docs-developers/server/api/index.md +++ b/docs-developers/server/api/index.md @@ -157,7 +157,7 @@ Failures a person can fix also carry a machine-readable **`code`** next to | `code` | Status | When | |---|---|---| -| `username_taken` | `409` | creating an account with a username that exists (`POST /admin/users`); a household member whose name leaves no free username (`POST /me/household/members`) | +| `username_taken` | `409` | creating an account with a username that exists (`POST /admin/users`). Adding a household member never answers it: the derived username is numbered with no cap | | `name_taken` | `409` | a library or share name already in use (create, rename) | | `last_admin` | `409` | demoting or disabling the last enabled admin | | `admin_needs_password` | `400` | an admin without a password (creating one, promoting a password-less account, or clearing an admin's password) | @@ -191,17 +191,20 @@ Failures a person can fix also carry a machine-readable **`code`** next to | `invalid_target` | `400` | a notification destination the server refuses (`POST`/`PATCH /admin/notifications`); `field` names the field (`kind`, `name`, `url`, `secret`, `events`) | | `too_many_targets` | `409` | `POST /admin/notifications` when the server has 20 destinations | | `demo_account` | `403` | a demo account on a route it can't use: the self-service routes (password, recovery code, API keys), sharing a collection, `GET /me/share-targets`, `PUT /me/household`, every `/me/household/members` route but the list | -| `kid_account` | `403` | a kid household member on `POST /auth/password`, `POST /auth/tokens`, `POST /auth/pair`, `POST /auth/recovery`, `PUT /me/household` or `PUT /me/collections/{id}/shares` | +| `kid_account` | `403` | a kid household member on `POST /auth/password`, `POST /auth/tokens`, `POST /auth/pair`, `POST /auth/recovery`, `PUT /me/household` or `PUT /me/collections/{id}/shares` (`api.notKid`, before the handler); also from inside the API key, recovery code, pairing and house sharing writes, for a request that passed the wrapper just before the owner's switch to kid committed | | `member_cannot_manage` | `403` | a household member on a `/me/household/members` route | | `feature_off` | `403` | adding a household member while `household.members_enabled` is off | | `not_allowed` | `403` | adding a household member while the admin turned off the caller's `can_add_members` | | `member_limit` | `409` | adding a household member at `household.max_members` | +| `member_disabled` | `409` | `POST /me/household/members/{id}/session` or `.../pair` for a member whose account the admin disabled (no token of theirs would work, so none is made) | | `invalid_name` | `400` | a household member's name that is empty, over 40 characters, or holds control characters | | `invalid_collections` | `400` | a member's `collection_ids` naming a collection that isn't the caller's own | | `member_not_admin` | `409` | `PATCH /admin/users/{id}` making a household member an admin, or assigning an admin as a member | | `member_fields_required` | `400` | `PATCH /admin/users/{id}` with a `managed_by` id but not both `member_kind` and `member_access`, or either of those without it | | `owner_is_member` | `409` | `PATCH /admin/users/{id}` assigning to an owner who is a household member | | `owner_is_demo` | `409` | `PATCH /admin/users/{id}` assigning to a demo account | +| `member_is_demo` | `409` | `PATCH /admin/users/{id}` assigning a demo account as someone's member | +| `kid_no_password` | `400` | `PATCH /admin/users/{id}` with a non-empty `password` for a kid, or beside assigning the account as a kid (`""`, clearing, is fine); `POST /auth/password` only if the owner's switch to kid commits during the request | | `self_managed` | `409` | `PATCH /admin/users/{id}` assigning a person to themselves | | `has_members` | `409` | `PATCH /admin/users/{id}` assigning a person who has household members of their own | | `invalid_keep_members` | `400` | `DELETE /admin/users/{id}?keep_members=` with an entry that isn't one of that person's members | @@ -401,7 +404,7 @@ Phase 8b-1 added one more, always `true` on a server that has it: | Flag | Gates | |---|---| -| `household_members` | [Household members](reference.md#household-members): `/me/household/members/**`, `managed_by` and `member_kind` on the `user` object, `member_sign_ins` on [`/me/devices`](reference.md#get-apiv1medevices) and its `?also_members=1`. Without it the player offers no "Add someone to your household". It never depends on the caller: whether this caller may add is the members list's `allowed` | +| `household_members` | [Household members](reference.md#household-members): `/me/household/members/**`, `managed_by`, `member_kind` and (for a member) `display_name` on the `user` object, `member_sign_ins` on [`/me/devices`](reference.md#get-apiv1medevices) and its `?also_members=1`. Without it the player offers no "Add someone to your household". It never depends on the caller: whether this caller may add is the members list's `allowed` | Gate on the flag rather than on the server version: an older server answers `size=` and `narrator=` by ignoring them (full art, the unfiltered list) and the diff --git a/docs-developers/server/api/reference.md b/docs-developers/server/api/reference.md index b80c839..b9fae82 100644 --- a/docs-developers/server/api/reference.md +++ b/docs-developers/server/api/reference.md @@ -137,7 +137,7 @@ Phase 8b-1 adds one more, always `true` on a server that has it and absent on an | Flag | Value | Gates | |---|---|---| -| `household_members` | always `true` | [Household members](#household-members): `/me/household/members/**`; `managed_by` and `member_kind` on the [`user`](#get-apiv1me); `member_sign_ins` and `?also_members=1` on [Your devices](#your-devices) | +| `household_members` | always `true` | [Household members](#household-members): `/me/household/members/**`; `managed_by`, `member_kind` and (for a member) `display_name` on the [`user`](#get-apiv1me); `member_sign_ins` and `?also_members=1` on [Your devices](#your-devices) | The flag never depends on the caller or on the admin's switches: whether **this** caller may add a member is the members list's `allowed`. @@ -372,11 +372,15 @@ omitted when there is none. `role` is `"admin"` or `"user"`. On a server with `household_members`, `managed_by` and `member_kind` are always present: both `null` for an account nobody manages, and for a [household member](#household-members) the owner (`{ "id": 4, "username": "sam" }`) -and `"kid"` or `"grownup"`. The same `user` object rides on every session envelope -(exchange, login, demo, a member's session). The member's `member_access` and -display name are not on it: they are the owner's business (the -[members list](#get-apiv1mehouseholdmembers)) and the admin's. An older player -ignores both fields; the server limits a kid whatever the player shows. +and `"kid"` or `"grownup"`. A member's `user` also carries `display_name`, the name +the owner typed for them (`users.display_name`), so the member's own device can greet +them by it; it is omitted for everyone else, a former member included (`api.meUser`). +The same `user` object rides on every session envelope (exchange, login, demo, a +member's session). The member's `member_access` is not on it: it is the owner's +business (the [members list](#get-apiv1mehouseholdmembers)) and the admin's, and the +player's `managed_by` never carries the owner's own typed name (only the admin's +does). An older player ignores the new fields; the server limits a kid whatever the +player shows. ### `GET /api/v1/addresses` @@ -403,7 +407,13 @@ below, share one rate limit (10 attempts per IP per 15 minutes) and are accounts`), so a throwaway session can't mint a durable credential. `POST /auth/password`, `POST /auth/recovery` and `POST /auth/tokens` (and `POST /auth/pair`) are also refused for a kid's account (403 `code: "kid_account"`, `not available for a -kid's account`): a kid signs in only on devices its owner sets up. +kid's account`): a kid signs in only on devices its owner sets up. The refusal comes +from one route wrapper (`api.notKid`) before the handler runs, and the recovery code, +API key and pairing writes refuse a kid again inside their own transaction +(`auth.refuseKid`), so a request that passed the wrapper just before the owner's +switch to kid committed still gets `403 kid_account` and leaves nothing behind. A +password racing that switch is refused inside `auth.UpdateUser` instead, as +`400 kid_no_password`. ### `POST /api/v1/auth/password` @@ -418,7 +428,7 @@ Response: `204 No Content`. | Status | Meaning | |---|---| -| `400` | password missing or too short | +| `400` | password missing or too short; `kid_no_password` (only when the owner's switch to kid commits between the wrapper and the write) | | `401` | `current_password` incorrect | | `403` | demo account (`demo_account`); a kid's account (`kid_account`) | | `429` | account-mutation limit tripped | @@ -573,7 +583,7 @@ authenticated request (`null` before any), `last_ip` that request's address, and `current` marks the token making this request. `member_sign_ins` (capability `household_members`; always present there, `[]` when -none) names the caller's own [household members](#household-members) holding a live +none, and always `[]` for a member caller, who has no members) names the caller's own [household members](#household-members) holding a live session or API key that was made **on that device**: a member signed in through `sign_in_here` or [`…/members/{id}/session`](#post-apiv1mehouseholdmembersidsession) records the caller's token as its `tokens.created_on_token_id`. `name` is the member's @@ -607,7 +617,9 @@ may revoke too, since revoking only reduces access. A token of the caller's that has expired but isn't revoked yet can still be revoked (`200`), though the list already hides it. Revoking also clears that browser's sign-in key, so a later password sign-in from it is announced as a new device. A self-revoke is not -written to the admin audit log. +written to the admin audit log, but each member `?also_members=1` signs out is: +`member.device_revoke` with the caller as actor, the member as target and the +caller's `device_id`, as on the [member route](#delete-apiv1mehouseholdmembersiddevicesdevice_id). | Status | Meaning | |---|---| @@ -2833,16 +2845,20 @@ what a member may listen to in `catalog` (the member rule; see - One level: an owner is never a member, a member never has members and is never an admin. -**Guards on every route below:** an API key is `403` (`denyAPIKey`; the list too), a -demo account `403` `code: "demo_account"`, a member caller `403` `code: -"member_cannot_manage"`, anonymous `401`. Someone else's member (or no such account) is -`404` `member not found` on every `{id}` route, so the answer never says whether the id +**Guards, in the order they answer.** Anonymous is `401` (`requireAuth`). On the +`{id}` routes the ids are read first: one that isn't a number is `400` (`invalid +member id`, or `invalid device id` for `{device_id}`). Then `memberOwner`: an API key +is `403` (`denyAPIKey`; the list too), a demo account `403` `code: "demo_account"`, a +member caller `403` `code: "member_cannot_manage"`. Create, session and pair then +spend the [self-service](#self-service-account) rate limit (the account limiter, as +`gateSelfService`'s), charged only after those refusals, so a demo's or a member's +refused calls never spend the address's shared budget (`429`). The body comes next +(decoded strictly: an unknown field, such as `role`, is `400 invalid request`), and +last the route's own transaction. Someone else's member (or no such account) is `404` +`member not found` on every `{id}` route, so the answer never says whether the id exists. Only **adding** checks the server's switch, the owner's switch and the cap: every other route keeps working on the caller's existing members when the feature is -off, so an owner can always rename, sign out and delete them. Create, session and pair -also take the [self-service](#self-service-account) rate limit (`gateSelfService`). The -bodies are decoded strictly: an unknown field (such as `role`) is `400 invalid -request`. +off, so an owner can always rename, sign out and delete them. **The member object** (`HouseholdMember`), as the list, the create and the `PATCH` answer it: @@ -2855,8 +2871,8 @@ answer it: "kind": "kid", "access": "collections", "created_at": "2026-10-13T10:00:00.000Z", - "collections": [ { "id": 4, "name": "Evie's books", "items": 2 } ], "devices": 1, + "collections": [ { "id": 4, "name": "Evie's books", "items": 2 } ], "last_listened_at": "2026-10-13T18:00:00.000Z" } ``` @@ -2878,8 +2894,9 @@ The caller's members and whether they may add one: { "allowed": true, "max": 6, "members": [ { "id": 12, "name": "Evie", "…": "…" } ] } ``` -It never refuses an ordinary signed-in session: when the caller may not add, -`allowed` is `false` with a `reason`, checked in this order: +It never refuses a signed-in session: a demo account and a member get `200` too. +When the caller may not add, `allowed` is `false` with a `reason`, checked in this +order (`auth.MayAddMembers`, the one ladder the create runs as well): | `reason` | Why | |---|---| @@ -2888,8 +2905,9 @@ It never refuses an ordinary signed-in session: when the caller may not add, | `feature_off` | the server's `household.members_enabled` is off | | `not_allowed` | the admin turned off the caller's `can_add_members` | -`max` is `household.max_members`. Being at the cap is **not** a reason: the client -compares `members.length` with `max`. An API key gets `403`. +Anyone else's `members` are listed whatever `allowed` says. `max` is +`household.max_members`. Being at the cap is **not** a reason: the client compares +`members.length` with `max`. An API key gets `403`. ### `POST /api/v1/me/household/members` @@ -2903,31 +2921,34 @@ Adds a member. | `sign_in_here` | bool | no | also sign the member in on the caller's device | | `collection_ids` | int[] | no | the caller's own collections to share with the member (below) | -In one transaction it checks the caller may add (demo, member, the server's switch, -the caller's switch, the cap, in that order), derives the username, inserts the -account (`role` user, an empty password hash, the three member columns and the typed -name), shares the collections and, with `sign_in_here`, issues a session. +In one transaction (`auth.CreateMember`) it checks the caller may add (demo, member, +the server's switch, the caller's switch, then the cap, in that order), then the name, +`kind` and `access`, derives the username, inserts the account (`role` user, an empty +password hash, the three member columns and the typed name), shares the collections +and, with `sign_in_here`, issues a session. Anything refused changes nothing. - **The username** is the name lowercased and folded to ASCII letters and digits (`Évie Marie` -> `evie-marie`, any run of anything else one `-`, at most 24 bytes, `member` when nothing is left), then that with the owner's username (`evie-sam`), - then numbered `-2` to `-99`: the first one no account has, ignoring case. Past `-99` - it is `409` `code: "username_taken"`. + then numbered `-2`, `-3` and on with no cap: the first one no account has, ignoring + case, read once inside the transaction, so there is always a free one and the + create never answers `username_taken`. - **`sign_in_here`** issues a session for the member named as the caller's own device, recording the caller's token as its `created_on_token_id` (so the caller's [device list](#get-apiv1medevices) names the member under `member_sign_ins`, and [`?also_members=1`](#delete-apiv1medevicesid) can sign it out). - **`collection_ids`** is the full set of the caller's collections to share with the - member (any `access`; the player sends it for `collections`). An id that isn't one of - the caller's own collections is `400 invalid_collections` and nothing is applied. The - [50-person share cap](#put-apiv1mecollectionsidshares) applies. `[]` is allowed: a - kid can start with nothing. + member (any `access`; the player sends it for `collections`), applied inside the same + transaction (`catalog.SetMemberCollections` through `auth.ShareFunc`). An id that + isn't one of the caller's own collections is `400 invalid_collections` and nothing is + applied. The [50-person share cap](#put-apiv1mecollectionsidshares) applies. `[]` is + allowed: a kid can start with nothing. Response `201`: ```json { "member": { "id": 12, "name": "Evie", "…": "…" }, - "session": { "token": "…", "user": { "id": 12, "username": "evie", "managed_by": { "id": 4, "username": "sam" }, "member_kind": "kid", "…": "…" }, "server_id": "…" } } + "session": { "token": "…", "user": { "id": 12, "username": "evie", "managed_by": { "id": 4, "username": "sam" }, "member_kind": "kid", "display_name": "Evie", "…": "…" }, "server_id": "…" } } ``` `session` (only with `sign_in_here`) is the usual session envelope, the same shape as @@ -2941,7 +2962,7 @@ code instead. | `400` | `invalid request` (malformed, an unknown field, a wrong type); `code: "invalid_name"`; a `kind` or `access` that isn't one of the two values (no `code`); `code: "invalid_collections"`; `a collection can be shared with at most 50 users` (a chosen collection already has 50 other people) | | `401` | anonymous | | `403` | API key; `demo_account`; `member_cannot_manage`; `code: "feature_off"`; `code: "not_allowed"` | -| `409` | `code: "member_limit"` (at `max`); `code: "username_taken"` (no free username for the name) | +| `409` | `code: "member_limit"` (at `max`) | | `429` | the self-service rate limit | ### `POST /api/v1/me/household/members/{id}/session` @@ -2951,23 +2972,45 @@ the caller's device, `created_on_token_id` the caller's token). Response `201`: session envelope. Used for "They'll also use this phone" and "Sign in on this device". +| Status | Meaning | +|---|---| +| `201` | the member's session envelope | +| `400` | `invalid member id` | +| `403` | API key; `demo_account`; `member_cannot_manage` | +| `404` | not the caller's member | +| `409` | `code: "member_disabled"`: the admin disabled the member's account, so no token of theirs would work (nothing is issued) | +| `429` | the self-service rate limit | + ### `POST /api/v1/me/household/members/{id}/pair` No body. Response `200`: a `PairingPayload` for the **member's** own device, built like -[`/auth/pair`](#post-apiv1authpair)'s: single use, 10 minutes, unlinked. It works for a -kid (who can't call `/auth/pair` itself). A device that signs in with it is the -member's own: it records no `created_on_token_id`. +[`/auth/pair`](#post-apiv1authpair)'s, with the same lifetime (`pairingTTL`, 10 +minutes): single use, unlinked. It works for a kid (who can't call `/auth/pair` +itself). A device that signs in with it is the member's own: it records no +`created_on_token_id`. + +| Status | Meaning | +|---|---| +| `200` | the pairing payload | +| `400` | `invalid member id` | +| `403` | API key; `demo_account`; `member_cannot_manage` | +| `404` | not the caller's member | +| `409` | `code: "member_disabled"` (as above; no pairing token is minted) | +| `429` | the self-service rate limit | ### `GET /api/v1/me/household/members/{id}/devices` Response `200`: a bare array of the member's live sessions and API keys, each shaped as a [`GET /me/devices`](#get-apiv1medevices) row with `current` always `false` and no -`member_sign_ins` (`[]` when none). +`member_sign_ins` (`[]` when none). `400` `invalid member id`, `403` as above, `404` +not the caller's member. No rate limit. ### `DELETE /api/v1/me/household/members/{id}/devices/{device_id}` -Signs the member out of one device. `204`; `404` `device not found` for a device that -isn't the member's live session or API key, `400` for an id that isn't a number. +Signs the member out of one device, in one transaction. `204`; `404` `member not +found` for anyone but the caller's member, then `404` `device not found` for a device +that isn't the member's live session or API key; `400` for an id that isn't a number; +`403` as above. Audited `member.device_revoke` (`device_id`). ### `PATCH /api/v1/me/household/members/{id}` @@ -2975,23 +3018,31 @@ Changes a member: any of `name`, `kind`, `access` and `collection_ids` (the same as the create; `collection_ids` replaces the full set: a share on each listed collection, and the member removed from every other collection the caller owns; other people's collections are never touched). `{}` is `400` `nothing to change`. -Response `200`: the member object. - -Switching a grown-up to a **kid** clears, in the same transaction, the member's -password (and the browsers it signed in from), revokes their API keys and unused -pairing tokens, and deletes their recovery code. Their signed-in sessions stay signed -in. Switching kind leaves `access` as it was. +Response `200`: the member object. Everything runs in one transaction +(`auth.UpdateMember`), with the ownership settled before the input is checked, so a +refused field changes nothing. + +Switching a grown-up to a **kid** clears, in the same transaction +(`clearKidCredentials`), everything a kid can't hold: the member's password (and the +browsers it signed in from), their API keys and unused pairing tokens, their recovery +code, their still-redeemable invites, their [house sharing](#listening-in-the-house) +(so a kid switched back to grown-up starts with it off and opts in again), and the +collections shared between them and anyone but the caller, both ways (theirs shared +with others, and others' shared with them). Their signed-in sessions stay signed in. +Switching kind leaves `access` as it was. | Status | Meaning | |---|---| | `200` | the member, as it is now | -| `400` | `invalid request`, `nothing to change`, `invalid_name`, a bad `kind` or `access`, `invalid_collections`, `a collection can be shared with at most 50 users` | +| `400` | `invalid member id`, `invalid request`, `nothing to change`, `invalid_name`, a bad `kind` or `access`, `invalid_collections`, `a collection can be shared with at most 50 users` | +| `403` | API key; `demo_account`; `member_cannot_manage` | | `404` | not the caller's member | ### `DELETE /api/v1/me/household/members/{id}` Deletes the member's account and all of its state (the schema's cascade, as -[`DELETE /admin/users/{id}`](#delete-apiv1adminusersid)). `204`. +[`DELETE /admin/users/{id}`](#delete-apiv1adminusersid)). `204`; `400` `invalid +member id`, `403` as above, `404` not the caller's member. ### What a member may and may not do @@ -3002,12 +3053,20 @@ Deletes the member's account and all of its state (the schema's cascade, as | `PUT /me/collections/{id}/shares` | yes | `403 kid_account` | | `POST /auth/password`, `POST /auth/tokens`, `POST /auth/pair`, `POST /auth/recovery` | yes | `403 kid_account` | | `PUT /me/household` | yes | `403 kid_account` (`GET` reads `false`) | -| `/me/household/members/**` | `403 member_cannot_manage` | `403 member_cannot_manage` | +| `/me/household/members/**` | `403 member_cannot_manage` (the list: `200`, `reason: "member"`) | the same | | `/admin/**` | `403` (role `user`) | `403` (role `user`) | +The `kid_account` refusals come from `api.notKid` before the handler (wrap any new +route a kid can't use), and the writes refuse a kid again inside their own +transaction (`auth.refuseKid` in `IssueAPIToken`, `GenerateRecoveryCode` and +`IssueOwnPairingToken`; `catalog.SetHouseholdSharing`), so a request that passed the +wrapper just before the owner's switch to kid committed is still `403 kid_account`. + A kid's password sign-in fails like any password-less account's (`401` on -[`/auth/login`](#post-apiv1authlogin)). **Audit:** each route that changes something -writes a `member.*` event with the owner as actor and the member as target (see +[`/auth/login`](#post-apiv1authlogin)). A member the admin disabled can't sign in at +all, like any disabled account, and their owner's session and pair routes answer +`409 member_disabled`. **Audit:** each route that changes something writes a +`member.*` event with the owner as actor and the member as target (see [Audit log](../backups-and-notifications.md#audit-log)). ## Casting @@ -3081,24 +3140,37 @@ All accounts, wrapped as `{ "users": [ … ] }`: "can_add_members": true, "members_count": 2, "display_name": "" + }, + { + "id": 12, + "username": "evie", + "role": "user", + "…": "…", + "managed_by": { "id": 4, "username": "sam", "display_name": "" }, + "member_kind": "kid", + "member_access": "collections", + "can_add_members": true, + "members_count": 0, + "display_name": "Evie" } ] } ``` Each user is the [`/me`](#get-apiv1me) object plus the household member fields -(capability `household_members`) only the admin sees: +(capability `household_members`) only the admin sees (`api.adminUser`): | Field | Meaning | |---|---| -| `managed_by` | `{ id, username }` of the owner for a [household member](#household-members), else `null` | +| `managed_by` | `{ id, username, display_name }` of the owner for a [household member](#household-members), else `null`. The owner's `display_name` is their own typed name (`""` unless they were once a member themselves): the console names an owner by it, else the username. The player's `managed_by` never carries it | | `member_kind` | `"kid"` or `"grownup"` for a member, else `null` | | `member_access` | `"all"` or `"collections"` for a member, else `null` | | `can_add_members` | the admin's per-person switch (default `true`) | | `members_count` | how many members this person has | -| `display_name` | the name the owner typed for a member (`""` for everyone else; kept when the member is made independent) | +| `display_name` | the name the owner typed for a member, always present (`""` for everyone else; kept when the member is made independent, though `/me` then leaves it off) | -The same fields are on `GET /admin/users/{id}`'s `user` and on the `PATCH` answer. +The same fields are on `GET /admin/users/{id}`'s `user`, on the `PATCH` answer and on +the create's. ### `POST /api/v1/admin/users` @@ -3110,10 +3182,11 @@ Create an account. | `password` | string | admins only | optional for non-admins (pairing-only accounts); required for `role: "admin"` | | `role` | string | yes | `"admin"` or `"user"` | -Response `201`: the created user object. `409` `username already taken` -(`code: "username_taken"`) on a duplicate username; `400` with a specific message -on a validation failure - `admin_needs_password`, `password_too_short` (see -[error codes](index.md#error-envelope-and-status-conventions)). +Response `201`: the created account as the admin's user object above (re-read after +the insert, so its stored defaults, `can_add_members` among them, are there). `409` +`username already taken` (`code: "username_taken"`) on a duplicate username; `400` +with a specific message on a validation failure - `admin_needs_password`, +`password_too_short` (see [error codes](index.md#error-envelope-and-status-conventions)). ### `GET /api/v1/admin/users/{id}` @@ -3122,7 +3195,7 @@ One account plus everything the console needs to manage it: ```json { "user": { "id": 4, "username": "sam", "role": "user", "disabled": false, - "has_password": false, "has_recovery": true, "is_demo": false }, + "has_password": false, "has_recovery": true, "is_demo": false, "…": "…" }, "accessible_libraries": [ { "id": 1, "name": "Audiobooks", "root": "/srv/audiobooks", "default_view": "hybrid", "sort_order": 0 } ], "shares": [ { "id": 2, "name": "Fantasy shelf", "description": "", "read_only": true, @@ -3141,17 +3214,28 @@ One account plus everything the console needs to manage it: "created_at": "2026-07-02T10:00:00Z" } ], - "members": [ { "id": 12, "username": "evie", "name": "Evie", "kind": "kid", "access": "collections", "disabled": false } ], + "members": [ { "id": 12, "name": "Evie", "username": "evie", "kind": "kid", "access": "collections", + "created_at": "2026-10-13T10:00:00.000Z", "devices": 1, "disabled": false } ], "member_collections": [] } ``` -`members` is this person's own household members (oldest first; `[]` for none). -`member_collections` is, for a member on `access: "collections"`, the owner's -collections shared with them (`{ id, name, items }`, `items` counted by the owner's -current access), else `[]`. `accessible_libraries` includes a member's **derived** -access (the owner's libraries for `all`, the libraries their shared collections reach -for `collections`), while `shares` stays the admin's direct grants only. +- `members` - this person's own household members, oldest first (`[]` for none): + each the owner's list's member (`id`, `name`, `username`, `kind`, `access`, + `created_at`, `devices`; without `collections` and `last_listened_at`) plus + `disabled`. +- `member_collections` - for a member on `access: "collections"`, the owner's + collections shared with them (`{ id, name, items }`, `items` counted by the owner's + current access), else `[]`. +- `accessible_libraries` includes a member's **derived** access (the owner's libraries + for `all`, the libraries their shared collections reach for `collections`), read + through the same member rule as the member's own requests, while `shares` stays the + admin's direct grants only. +- For a household member only, `owner_libraries` (the libraries the owner reaches + now, as the member's `all` access would follow them) and `owner_is_admin` (an admin + owner gives every library), so the console's Access tab needs no second detail. + `[]` and `false` for a member whose owner derives nothing (bad data). Absent for + everyone else. `shares` marks whole-library grants with `whole_library_id` (see [`GET /admin/shares`](#get-apiv1adminshares)). `auth_codes` is **invite metadata only** (never the code itself, and never @@ -3166,32 +3250,50 @@ Edit an account in place - any subset of: | Body field | Type | Notes | |---|---|---| | `role` | string | `"admin"` \| `"user"` | -| `password` | string | `""` clears the password (non-admins only) | +| `password` | string | `""` clears the password (non-admins only); never a non-empty one for a kid | | `disabled` | bool | reversible lockout; disabling revokes nothing but blocks all token use | | `can_add_members` | bool | the per-person switch: may this person add household members | | `managed_by` | int \| `null` | an owner's id: "Assign to someone", with `member_kind` and `member_access` beside it. `null`: "Make independent" | | `member_kind` | string | `"kid"` \| `"grownup"`; only with a `managed_by` id | | `member_access` | string | `"all"` \| `"collections"`; only with a `managed_by` id | -The body is decoded strictly. The password is applied first, then the member fields, -then the role (so "Make independent" and "make admin" work in one request). +The body is decoded strictly, and the whole of it is **one transaction** +(`auth.UpdateUser`): every field is checked against the account before anything is +written, so a refused field changes nothing, not even the fields before it. The +checks run in this order, each against the account as the ones before it leave it: +the password, the household, the role, the disabled state, then `can_add_members`. +So a password and "make admin" work in one request on a password-less account, and +"Make independent" and "make admin" work in one request on a member. - **Assign to someone** (`managed_by: `) makes the account a member of that owner with the given kind and access. Its own grants stay (the member rule is a union). - Neither the owner's cap nor their switch applies. As a kid, the account's password, - API keys, unused pairing tokens and recovery code are cleared; its sessions stay. - Moving a member to another owner works the same way. + Neither the owner's cap nor their switch applies: the admin decides. Moving a member + to another owner works the same way. +- **Joining another owner's household** (an assign to an owner the account doesn't + already have: a first assign, or a move) signs the + person out of what someone else set up for them (`auth.leaveOwners`): every session + and API key made on a device that isn't the new owner's (by `created_on_token_id`, + so the old owner's phone can't carry the new owner's access), and their open + unlinked pairing tokens. The devices they set up themselves stay signed in. +- **Assigning as a kid** clears what a kid can't hold, as the owner's + [switch to kid](#patch-apiv1mehouseholdmembersid) does: the password, API keys, + unused pairing tokens, recovery code, still-redeemable invites, house sharing, and + the collections shared between them and anyone but the new owner, both ways. Their + sessions stay. A non-empty `password` beside it is `400 kid_no_password`; `""` is + fine. - **Make independent** (`managed_by: null`) clears the three member columns: the - account keeps only its direct grants, and `display_name`. A no-op on an account - that isn't a member. + account keeps only its direct grants, and `display_name`. It also revokes their + open unlinked pairing tokens, and nothing else: the devices their old owner signed + them in on stay signed in, now reaching only the account's own grants. A no-op on + an account that isn't a member. Response `200`: the updated user object (with the fields above). | Status | Meaning | |---|---| -| `400` | admin must keep a password (`admin_needs_password`) / password too short (`password_too_short`); `managed_by` that isn't a positive id or `null`; `code: "member_fields_required"` (`managed_by: ` without both `member_kind` and `member_access`, or either of those without it); a `kind` or `access` that isn't one of the two values | +| `400` | admin must keep a password (`admin_needs_password`) / password too short (`password_too_short`); `managed_by` that isn't a positive id or `null`; `code: "member_fields_required"` (`managed_by: ` without both `member_kind` and `member_access`, or either of those without it); a `kind` or `access` that isn't one of the two values; `code: "kid_no_password"` (a non-empty `password` for a kid, or beside assigning one) | | `404` | user not found (the account, or the owner named by `managed_by`) | -| `409` | would demote/disable the last enabled admin (`last_admin`); `code: "member_not_admin"` (making a member an admin, or assigning an admin as a member); `code: "owner_is_member"` (the owner is a member); `code: "owner_is_demo"` (the owner is a demo account); `code: "self_managed"` (`managed_by` is the account itself); `code: "has_members"` (the account has members of its own) | +| `409` | would demote/disable the last enabled admin (`last_admin`); `code: "member_not_admin"` (making a member an admin, or assigning an admin as a member); `code: "owner_is_member"` (the owner is a member); `code: "owner_is_demo"` (the owner is a demo account); `code: "member_is_demo"` (the account being assigned is a demo account); `code: "self_managed"` (`managed_by` is the account itself); `code: "has_members"` (the account has members of its own) | `user.update` in the audit log gains `can_add_members`, and `managed_by` (the owner's username, or `null`), `member_kind` and `member_access` when an Assign or Make @@ -3205,10 +3307,18 @@ listening totals, share grants) via cascade; files on disk are untouched. Response: `204 No Content`. The person's [household members](#household-members) are deleted with them, in the -same transaction, except those named in `?keep_members=,`: each kept member -becomes an ordinary account (the three member columns cleared, so only their direct -grants remain). The audit event `user.delete` carries `members_deleted` (a count) and -`members_kept` (usernames). +same transaction, except those named in `keep_members`, as comma-separated ids +(`?keep_members=3,7`), the parameter repeated (`?keep_members=3&keep_members=7`), or +both: every value counts. Each kept member becomes an ordinary account (the three +member columns cleared, so only their direct grants remain) and loses their open +unlinked pairing tokens, as with Make independent. The audit event `user.delete` +carries `members_deleted` (a count) and `members_kept` (usernames), only for someone +who had members. + +The checks answer in this order: an id that isn't a number, then a malformed +`keep_members` (`400 invalid_keep_members`), then a self-delete (`400 +cannot_delete_self`), then the account (`404`), then, inside the transaction, the +last-admin rule and the keep list. | Status | Meaning | |---|---| @@ -5995,7 +6105,10 @@ and `AUDIOSILO_*` variable each one is, and whether it needs a restart (see }, "household": { "members_enabled": true, - "max_members": 6 + "max_members": 6, + "max_members_default": 6, + "max_members_min": 1, + "max_members_max": 50 }, "backups": { "schedule": "daily:03:00", @@ -6029,7 +6142,8 @@ effect. Lists are never `null`. | `demo.max_users` | `null` = the default cap (`max_users_default`); `0` = no limit | | `demo.idle_ttl` | `""` = 24h | | `household.members_enabled` | people may add [household members](#household-members) from the player (default `true`); off refuses new ones only (`403 feature_off`) | -| `household.max_members` | members per person, 1-50 (default 6) | +| `household.max_members` | members per person, 1-50 (default 6); outside the range is `400 invalid_setting` (`field: "household.max_members"`) | +| `household.max_members_default`, `household.max_members_min`, `household.max_members_max` | read-only facts: the cap's default (`config.DefaultMaxMembers`, 6) and range (`MinMaxMembers`, `MaxMaxMembers`: 1 and 50), so the console names the range and checks a value before saving | | `backups.schedule` | `""` (off), `daily:HH:MM` or `weekly:DAY:HH:MM` in the server's time zone; stored in canonical form (see [Backups](#admin-backups)) | | `backups.keep` | how many scheduled backups are kept, 1-365 (manual and before-restore backups stay until deleted) | | `backups.dir` | read-only: changed only in `config.yaml` or `AUDIOSILO_BACKUP_DIR`; `""` = `/backups` (`GET /admin/backups`' `status.dir` has the folder in use) | diff --git a/docs-developers/server/auth-and-security.md b/docs-developers/server/auth-and-security.md index 46cc465..4d61d7b 100644 --- a/docs-developers/server/auth-and-security.md +++ b/docs-developers/server/auth-and-security.md @@ -112,7 +112,7 @@ every axis: more than a day. - **Still the person's.** Deleting the account deletes it with the account's other tokens. Disabling the account only **suspends** it, as it does every token - (`SetDisabled` revokes nothing; `lookupToken` checks the user on every request): it + (disabling, `UpdateUser`'s `disabled`, revokes nothing; `lookupToken` checks the user on every request): it is refused while the account is disabled and works again if the account is enabled before it expires, so signing out the device that minted it is what ends it for good. The media handlers check the person's **current** share scope, so a revoked @@ -256,7 +256,8 @@ Three fences keep a demo session from becoming a durable login: never offered as share targets. - A demo account can't add [household members](#household-members) (every member route but the list is `403`; the list answers `allowed: false`, `reason: "demo"`), - and can't be anyone's owner. + and is never anyone's owner or member: the admin's Assign refuses both + (`409 owner_is_demo`, `409 member_is_demo`). - Every demo refusal carries `code: "demo_account"` (`nonDemoAccount`). - A demo account can't share its listening with the house (`PUT /me/household` is `403`, `nonDemoAccount`), and demo accounts never appear in anyone's @@ -283,14 +284,18 @@ or change their own password: ## Admin guards Account-safety invariants live in `internal/auth`, not in handlers, so every -caller gets them: - -- **`ErrLastAdmin`** - the last *enabled* admin cannot be demoted - (`SetRole`), disabled (`SetDisabled`), or deleted (`DeleteUser`). The console - can never be locked out. +caller gets them. The admin's `PATCH /admin/users/{id}` is **one** `auth.UpdateUser` +transaction (`SetPassword` is a password-only call of it): every change in the body +is checked against the account, each as the ones before it leave it (password, +household, role, disabled, `can_add_members`), before anything is written, so a +refused field changes nothing. + +- **`ErrLastAdmin`** - the last *enabled* admin cannot be demoted or disabled + (`UpdateUser`) or deleted (`DeleteUser`); both read the account and the guard + inside the write, so two admins removing each other at once can't both pass. The + console can never be locked out. - **`ErrAdminNeedsPassword`** - an account cannot become (or remain) an admin - without a password: enforced on create, on promote (`SetRole`), and on - password clear (`SetPassword`). + without a password: enforced on create, on promote and on password clear. - **No self-delete** - enforced additionally in the delete *handler*: an admin may disable their own account (reversible) but never delete it (irreversible). `DeleteUser` cascades sessions, auth codes, progress, @@ -302,13 +307,22 @@ caller gets them: `Authenticate` rejects empty-hash accounts outright, so a password-less user can only ever authenticate via code pairing (or, for a [household member](#household-members), a session their owner makes). -- **`ErrMemberNotAdmin`** - a household member is never an admin: `SetRole` - refuses promoting one, and `AssignMember` refuses making an admin a member - (`409 member_not_admin`); "Make independent" comes first. +- **`ErrMemberNotAdmin`** - a household member is never an admin: `UpdateUser` + refuses promoting one and assigning an admin as a member (`409 member_not_admin`); + "Make independent" comes first, and works in the same request as the promotion. +- **`ErrKidNoPassword`** - a kid holds no password: `UpdateUser` refuses a non-empty + password on a kid, or beside assigning the account as a kid (`400 + kid_no_password`); clearing one is fine. +- **The Assign refusals** (`checkAssign`, inside the transaction): an unknown owner + (`404`), the account itself (`ErrSelfManaged`), an owner who is a member + (`ErrOwnerIsMember`) or a demo account (`ErrOwnerIsDemo`), a demo account as the + member (`ErrMemberIsDemo`), an admin (`ErrMemberNotAdmin`), and someone with members + of their own (`ErrHasMembers`), each `409` with its code. - **`DeleteUser(ctx, id, keep)`** deletes the person's household members with them, in the same transaction, except the ids in `keep` (cleared to ordinary - accounts); a `keep` id that isn't their member is `ErrInvalidKeepMembers` and - nothing changes. The last-admin rule is checked first. + accounts, their open pairing tokens revoked); a `keep` id that isn't their member + is `ErrInvalidKeepMembers` and nothing changes. The last-admin rule is checked + first. ## Authorization: shares → Scope @@ -448,6 +462,10 @@ denied test: hasn't listened in 24 hours sees others without showing up for them). - **Who can appear.** Other users only (never the caller), enabled, not demo, with sharing on. +- **Never a kid.** A kid [household member](#household-members) has no + `household_sharing` row: becoming a kid deletes it, and `PUT /me/household` refuses + a kid both at the route and inside the write. So the opt-in rule alone leaves kids + out, as a row and as a viewer, with no kid filter of its own. - **What of them.** One row per person: their newest session recorded on this server (`import_id = 0`, not backfilled, listened > 0) within 24 hours. - **Only books the caller may open.** That newest book passes the **caller's** @@ -471,13 +489,11 @@ security model. **One level, never admin.** An owner is never a member, a member never has members (`ErrHasMembers`), and a member is never an admin (`ErrMemberNotAdmin`), so the -admin role is never derived. Callers of the scope functions keep passing `isAdmin = -u.Role == "admin"`; the owner's admin-ness is read inside the member rule, never from -the caller. +admin role is never derived. A demo account is never either (`ErrOwnerIsDemo`, +`ErrMemberIsDemo`). -**The member rule** (`catalog/members.go`, in `UserScope`, `UserScopes` and -`AccessibleLibraries`; decided per request, with no cache to invalidate). A member's -scope is the union of: +**The member rule** (`catalog/members.go`; decided per request, with no cache to +invalidate). A member's scope is the union of: - their own admin grants (unchanged); - for `all`: the owner's **current** scopes, computed with the owner's role. An admin @@ -489,57 +505,101 @@ scope is the union of: item with an empty path never grants a whole library. A member whose owner is themselves, or is a member (data the writes refuse), derives -nothing. A disabled owner changes nothing for their members. `UserShares` (the -console's Access tab) stays the direct grants only; the console lists the derived -access beside it. Every consumer follows from the three functions: browse, search, -media, cast mint and cast media, `/next`, the car and household listening. A book -removed from a kid's collection, the collection unshared or deleted, or a library -taken from the owner, is `403` on the next request, including through an existing -cast token (the media handler checks the person's current access). +nothing. A disabled owner changes nothing for their members. + +**The rule rides on the request.** `auth.lookupToken` reads the account's +`managed_by`, `member_kind` and the rule (`auth.User.MemberRule`: the owner, the +access, whether the owner is an admin) in its one statement, through a primary-key +`LEFT JOIN` of the owner (`memberColumns`/`memberScan`, which `getUser` shares). The +transport passes it on as a `catalog.Caller` (`api.callerOf`) to `CallerScope`, +`CallerScopes` and `CallerLibraries`, so a non-member's scopes cost the one grants +statement they always did and a kid's refusal reads nothing. `UserScope`, +`UserScopes` and `AccessibleLibraries` (id and `isAdmin`) read the rule themselves +(`memberRuleOf`, the same conditions) for a caller known only by id, such as the +importer; `TestMembers_RuleReadsAgree` holds both readings to the same scopes for +every kind of account, bad data included. New transport code builds scopes from the +request's user with `callerOf`, never from an id. `UserShares` (the console's Access +tab) stays the direct grants only; the console's detail carries the derived access +beside it (`accessible_libraries`, and a member's `owner_libraries`). Every consumer +follows from these functions: browse, search, media, cast mint and cast media, +`/next`, the car and household listening. A book removed from a kid's collection, the +collection unshared or deleted, or a library taken from the owner, is `403` on the +next request, including through an existing cast token (the media handler checks the +person's current access). **Long path lists.** A `collections` member can hold a thousand item paths. Over 32 paths, `pathFilterSQL`/`scopesFilterSQL` switch to a single-parameter form (`pathsJSONFilterSQL`: a row's path and each of its ancestors looked up in one JSON -list, the same rows as the `OR` form, which could not hold 1,000 arms at all). +list, the same rows as the `OR` form, which could not hold 1,000 arms at all; a path +that isn't valid UTF-8, which JSON can't carry exactly, keeps an `OR` arm of its own). `BenchmarkMemberScopes` (3,000 books, a 1,000-item kid against a whole-library -listener) records the cost: browse 2.4 ms vs 0.27 ms, search 9.2 ms vs 5.6 ms, the -progress list 4.8 ms vs 1.7 ms. +listener, through the `Caller` path) records the cost: browse 2.4 ms vs 0.26 ms, +search 9.1 ms vs 5.6 ms, the progress list 4.9 ms vs 1.7 ms, and the scope load +itself 1.05 ms vs 0.017 ms. **Creating accounts without an admin** (`auth/members.go`, `CreateMember`, one -transaction): the owner is checked (not demo, not a member, the server's -`household.members_enabled`, their `can_add_members`, under `household.max_members`), -the name is validated, the username derived (never chosen by the caller, so it can't -claim an existing one), and the account inserted with `role = 'user'` and an empty +transaction): the owner is checked (`MayAddMembers`: not demo, not a member, the +server's `household.members_enabled`, their `can_add_members`; then under +`household.max_members`), the name is validated, the username derived (never chosen +by the caller, so it can't claim an existing one; numbered with no cap, so there is +always a free one), and the account inserted with `role = 'user'` and an empty password hash. The body is decoded strictly, so a `role` field is `400`. An API key can't reach any member route (`denyAPIKey`), and create, session and pair take the -self-service rate limit. - -**Kids.** A kid account holds no password, API key, pairing token or recovery code: -`POST /auth/password`, `POST /auth/tokens`, `POST /auth/pair` and `POST -/auth/recovery` answer `403 kid_account`, and switching a member to a kid (by the -owner, or the admin's Assign) clears all four in the same transaction -(`clearKidCredentials`), leaving its signed-in sessions alone. A kid signs in only on -devices the owner sets up: a session made on the owner's device, or the member -pairing code (`POST /me/household/members/{id}/pair`, single use, 10 minutes). +account rate limit, charged only after the API-key, demo and member refusals. + +**Kids.** A kid account holds no password, API key, pairing token, recovery code, +invite or house sharing, and shares collections only with its owner. `POST +/auth/password`, `POST /auth/tokens`, `POST /auth/pair`, `POST /auth/recovery`, `PUT +/me/household` and `PUT /me/collections/{id}/shares` answer `403 kid_account` from one +route wrapper (`api.notKid`, before the handler; wrap any new route a kid can't use). +The writes that mint a credential or a share of the house refuse a kid again inside +their own transaction (`auth.refuseKid` in `IssueAPIToken`, `GenerateRecoveryCode` and +`IssueOwnPairingToken`; `catalog.SetHouseholdSharing`), so a request that passed the +wrapper just before the owner's switch to kid committed can't leave the kid holding +one; a password racing it is refused inside `UpdateUser` (`ErrKidNoPassword`). +Switching a member to a kid (by the owner, or the admin's Assign) clears, in the same +transaction (`clearKidCredentials`): the password (and the browsers it signed in +from), API keys, open pairing tokens, the recovery code, still-redeemable invites +(`supersedeActiveInvites`, whose linked pairing tokens cascade), the +`household_sharing` row (so a kid made a grown-up again starts with house sharing off +and opts in again), and the collections shared between the kid and anyone but their +owner, both ways (so no sharer is left with a kid in a list the server would refuse +back). Its signed-in sessions stay. A kid signs in only on devices the owner sets up: +a session made on the owner's device, or the member pairing code (`POST +/me/household/members/{id}/pair`, single use, the same 10 minutes as `/auth/pair`). Turning kids mode off in a player never widens anything: the server decides access. **Visibility.** A kid is listed by `ShareTargets` only to their owner, and `SetCollectionShares` refuses a kid target from anyone else with `ErrUnknownUser` -(the same answer as an unknown id, so it can't be used to probe who is a kid). A kid -can't share a collection (`403 kid_account`). `HouseListening` and -`HouseholdSharing` leave kids out both ways: never a row, never a viewer, never -sharing (see [what other people see](#listening-in-the-house-what-other-people-see)). +(`kidOnlyToOwnerSQL`; the same answer as an unknown id, so it can't be used to probe +who is a kid). `HouseListening` and `HouseholdSharing` leave kids out both ways: +never a row, never a viewer, never sharing, because a kid has no `household_sharing` +row (see [what other people see](#listening-in-the-house-what-other-people-see)). **Ownership.** Every `{id}` member route loads the member through `memberOf`, which answers `ErrNotMember` (404) for anyone who isn't the caller's member, so another person's member is indistinguishable from no such account. +**A disabled member.** The admin's disable stops every token of the member's, as for +anyone. The owner's session and pair routes then answer `409 member_disabled` +(`ErrMemberDisabled`, read inside their transaction) instead of minting a token that +could never work; the owner can still rename, sign out and delete the member. + **Sign-ins made on the owner's device.** A member session made through `sign_in_here` or `…/session` records the owner's token in -`tokens.created_on_token_id`. It is information only (a token's validity never reads -it): signing the owner's device out leaves those sessions alone unless the owner asks -`?also_members=1`, which revokes them in the same transaction, restricted to -`managed_by = caller`. +`tokens.created_on_token_id`. A token's validity never reads it. Signing the owner's +device out leaves those sessions alone unless the owner asks `?also_members=1`, which +revokes them in the same transaction, restricted to `managed_by = caller`, and audits +each member signed out (`member.device_revoke`). + +**Moving between households** (`auth.leaveOwners`). When the admin assigns a person to +an owner they don't already have (a first assign, or a move), the person is signed +out of what someone else set up for them: every session and API key whose +`created_on_token_id` is a device of anyone but the new owner (which would otherwise +carry the new owner's access to the old owner's phone), and their open unlinked +pairing tokens. Leaving a household ("Make independent", or kept by `keep_members` +when the owner is deleted) revokes the open unlinked pairing tokens only. The +person's own devices stay signed in either way. ## Path traversal: `library.SafeJoin` @@ -570,7 +630,7 @@ Two mechanisms in `internal/api/ratelimit.go`, six buckets wired in `api.New`: | `loginLimiter` | Failure lockout per IP (`limiter`) | 10 failures / 15 min | `POST /auth/login` | | `redeemLimiter` | Failure lockout per IP | 10 failures / 15 min | `POST /auth/redeem` | | `demoLimiter` | **Attempt** cap per IP (`Acquire`) | 5 / 15 min | `POST /demo/session` | -| `accountLimiter` | **Attempt** cap per IP (`Acquire`) | 10 / 15 min | `POST /auth/password`, `POST /auth/recovery`, `/auth/tokens` (create/list/revoke) | +| `accountLimiter` | **Attempt** cap per IP (`Acquire`) | 10 / 15 min | `POST /auth/password`, `POST /auth/recovery`, `/auth/tokens` (create/list/revoke); adding a household member, and a member's session and pairing code (`POST /me/household/members`, `.../{id}/session`, `.../{id}/pair`), charged only after the API-key, demo and member refusals | The failure-lockout `limiter` has two usage patterns: `Allowed`/`Fail`/`Reset` counts only *failed* attempts (right for login/redeem, where success should not diff --git a/docs-developers/server/backups-and-notifications.md b/docs-developers/server/backups-and-notifications.md index 2c258f6..653e282 100644 --- a/docs-developers/server/backups-and-notifications.md +++ b/docs-developers/server/backups-and-notifications.md @@ -156,8 +156,8 @@ key) or `system`. No IP address is stored. | Area | Actions | |---|---| -| `user` | `create`, `update` (details gain `can_add_members`, and `managed_by` (the owner's username, or `null`), `member_kind` and `member_access` when an Assign or Make independent changes them), `delete` (details: `members_deleted`, a count, and `members_kept`, usernames), `recovery_clear` | -| `member` | `create` (details: `member`, `kind`, `access`, `sign_in_here`), `update` (`member` plus what changed: `name`, `kind`, `access`, and `collections` as a count), `delete`, `session`, `pair`, `device_revoke` (`device_id`): [household members](api/reference.md#household-members) managed from the player. The actor is the owner, a non-admin, and the target the member's username | +| `user` | `create`, `update` (details gain `can_add_members`, and `managed_by` (the owner's username, or `null`), `member_kind` and `member_access` when an Assign or Make independent changes them), `delete` (details, only for someone who had household members: `members_deleted`, a count, and `members_kept`, usernames), `recovery_clear` | +| `member` | `create` (details: `member`, `kind`, `access`, `sign_in_here`), `update` (`member` plus what changed: `name`, `kind`, `access`, and `collections` as a count), `delete`, `session`, `pair`, `device_revoke` (`member`, `device_id`; also one per member signed out by `DELETE /me/devices/{id}?also_members=1`, with the owner's device id): [household members](api/reference.md#household-members) managed from the player. The actor is the owner, a non-admin, and the target the member's username | | `invite` | `create`, `rotate`, `revoke` | | `library` | `create`, `update`, `reorder`, `folder_override`, `delete` | | `book` | `edit`, `bulk_edit`, `enrichment`, `cover_set`, `cover_remove` | diff --git a/docs-developers/server/configuration.md b/docs-developers/server/configuration.md index cb6246e..2555c43 100644 --- a/docs-developers/server/configuration.md +++ b/docs-developers/server/configuration.md @@ -174,7 +174,11 @@ Both are read per request through the API's live config, so a change applies at | `household.members_enabled` | bool, `true` | Anyone with an account but a demo account may add members (`POST /me/household/members`). Off refuses new ones only (`403 feature_off`); existing members keep working and their owners can still manage them. The per-person switch is the admin's `can_add_members` on each account (`PATCH /admin/users/{id}`), not a key here | | `household.max_members` | int, `6` | Members per person, `1` to `50` (`config.MinMaxMembers`/`MaxMaxMembers`); at it, adding is `409 member_limit`. Outside the range `Validate` refuses the config (a value from the environment included, so the server doesn't start) | -A `config.yaml` written before the section keeps the defaults. +A `config.yaml` written before the section keeps the defaults. The admin's settings +envelope carries the cap's default and range beside the two settings, as read-only +facts (`household.max_members_default`, `_min`, `_max`), so the console names the +range and refuses a value outside it before saving (see +[`GET /admin/settings`](api/reference.md#get-apiv1adminsettings)). ### Community metadata (`metadata.*`) @@ -552,7 +556,7 @@ reason, and nothing is applied (all or nothing). The handler then writes | `demo.library` | trimmed; must be the name of an existing library | | `demo.max_users` | `null` (the default, 200) or 0 (no limit) to 100000 | | `demo.idle_ttl` | `""` (24h) or a positive Go duration | -| `household.max_members` | an integer from 1 to 50 (`fixMaxMembers`) | +| `household.max_members` | an integer from 1 to 50 (checked by `Validate`) | | `backups.schedule` | `""`, `daily:HH:MM` or `weekly:DAY:HH:MM` (`backup.ParseSchedule`), stored in canonical form | | `backups.keep` | an integer from 1 to 365 (checked by `Validate`) | diff --git a/docs-developers/server/data-model.md b/docs-developers/server/data-model.md index d853c65..96ca4d3 100644 --- a/docs-developers/server/data-model.md +++ b/docs-developers/server/data-model.md @@ -140,7 +140,7 @@ per-person switch); `display_name` (`TEXT NOT NULL DEFAULT ''`, the name the own typed for a member, `''` for everyone else). The first three are **all set or all NULL**: SQLite can't add a table CHECK with `ALTER TABLE`, so `auth` (`members.go`) writes and clears them together and a row that says otherwise reads as no member -(`scanUser`). `SET NULL` only covers a member an admin kept when deleting the +(`memberScan`). `SET NULL` only covers a member an admin kept when deleting the owner (`auth.DeleteUser` deletes the others explicitly and clears the kept ones' other two columns in the same transaction). Durable: this is the account, not the index. @@ -169,9 +169,12 @@ signed in. `created_on_token_id` *(0044; FK to `tokens` `ON DELETE SET NULL`, partial index `idx_tokens_created_on`)* records, for a household member's session made on their owner's device (`sign_in_here`, `…/members/{id}/session`), the owner's token it was -made on. It is **information only**, never part of a token's validity: it names the -members [`GET /me/devices`](api/reference.md#get-apiv1medevices) lists under -`member_sign_ins`, and which tokens `DELETE /me/devices/{id}?also_members=1` revokes. +made on. It is never part of a token's validity: it names the members +[`GET /me/devices`](api/reference.md#get-apiv1medevices) lists under +`member_sign_ins`, which tokens `DELETE /me/devices/{id}?also_members=1` revokes, and, +when the admin moves the person into another owner's household, which sessions and +API keys were made on someone else's device and are revoked with the move +(`auth.leaveOwners`). The admin console lists live session and API-key tokens as **devices** (never pairing or cast tokens) (`auth.ListDevices`) and can revoke one (`auth.RevokeDevice`). diff --git a/docs-users/admin/books.md b/docs-users/admin/books.md index bb4bf02..83dc673 100644 --- a/docs-users/admin/books.md +++ b/docs-users/admin/books.md @@ -312,8 +312,9 @@ cover larger than 5 MB is scaled down to fit within 1600 pixels first. their listening sessions on it (see [Editing someone's progress](users-and-invites.md#editing-someones-progress)). - **Who can see this** - each [share](sharing.md) that includes the book, and - through which folder, or "Whole-library access". If no share includes it, - only admins can listen to it. **Add to a share** adds the book to a share + through which folder, or "Whole-library access". If no share includes it: + "No share includes this book yet. Only admins, and the people in an admin's + household, can listen to it." **Add to a share** adds the book to a share from here. ### Files on disk diff --git a/docs-users/admin/server.md b/docs-users/admin/server.md index 593a154..428a844 100644 --- a/docs-users/admin/server.md +++ b/docs-users/admin/server.md @@ -323,9 +323,10 @@ account, managed by the person who added it." (See - **People can add household members** - on out of the box. Off stops new members being added; members already added keep working, and the people who added them can still manage them. -- **Members per person** - from 1 to 50 (6 unless you change it). Lowering it - never removes anyone: someone already over the new number just can't add - more. +- **Members per person** - from 1 to 50 (6 unless you change it). A number + outside that range, or an empty box, says "Enter a whole number from 1 to 50." + under the field, and **Save** stays off until it's fixed. Lowering it never + removes anyone: someone already over the new number just can't add more. Both apply at once, with no restart. To stop one person adding members, turn off **Can add household members** on their Account tab instead. The @@ -450,7 +451,8 @@ restore at start, are listed under **AudioSilo**. What is recorded: changes to accounts, invites, devices signed out, shares and access, household members (added, changed, signed in on a device, given a pairing code, a device signed out, or deleted, by the person who manages them -in the player, who is named as the one who did it), people's progress, libraries, books and covers, ignored health issues, +in the player, who is named as the one who did it; signing out a lost phone with +its members ticked records each member signed out), people's progress, libraries, books and covers, ignored health issues, settings, backups (including every download, restore and cancelled restore), notification destinations, and answers to the [support card](console-tour.md#the-support-card) ("Answered the support card", diff --git a/docs-users/admin/users-and-invites.md b/docs-users/admin/users-and-invites.md index b8ae8b4..653a74e 100644 --- a/docs-users/admin/users-and-invites.md +++ b/docs-users/admin/users-and-invites.md @@ -32,9 +32,10 @@ accounts and how many were active this month. Each card shows: "No devices". Personal API keys aren't counted here; the [Devices page](#the-devices-page) lists them. -Click a card to open [that person's page](#a-persons-page). A person's -household members come straight after their card, so a household reads as one -run of cards. +Click a card to open [that person's page](#a-persons-page). A household is +grouped into one block across the page, headed *Adam's household*: the card of +the person who added the members first, then each member's card. Someone with no +members, or a member whose owner isn't on the page, has a card of their own. ## Inviting someone @@ -161,7 +162,10 @@ and the same **Rotate** and **Revoke** buttons, as the ### Sign-in -- **Password** - **Set a password** (or **Change password**) for them. It must +- **Password** - for a kid's account there is nothing to set. The card says + instead how they sign in: "Kids sign in without a password: Adam signs them + in, or uses “Set up their own device” in the player." For anyone else, + **Set a password** (or **Change password**) for them. It must be at least 8 characters. A password lets them sign in to the web player by username as well as on paired devices; their paired devices stay signed in. The console can set or change a password but not remove one. @@ -237,27 +241,42 @@ For a household member it shows **Managed by** their owner instead, with: directly, and stop getting books through their owner, who can no longer manage the account. A kid's account has no password: the devices already signed in stay signed in, and to add another, [pair a device](#a-persons-page) - for them or set a password. -- **Assign to someone** - moves them to another person's household. + for them or set a password. Any code their owner made for their own device that + hasn't been used yet stops working. +- **Assign to someone** - moves them to another person's household, and signs + them out on the devices someone else signed them in on, such as their old + owner's phone. **Assign to someone** also works for an ordinary account, such as one you made for a child before household members existed. Pick **Whose household**, **Kid or grown-up** and **What they can listen to**; the person you pick manages the -account from then on. Their own grants stay. Making someone a kid removes their -password, API keys and unused pairing codes (their signed-in devices stay -signed in); choosing **Only collections** means they see nothing through their -new owner until the owner shares a collection with them. It isn't offered for an -admin, and is greyed out, with the reason, for someone who has household -members of their own. A household member or a demo account can't be the owner. -The owner's own cap and switch don't apply: you decide. +account from then on. Their own grants stay, and so do the devices they set up +themselves; any device someone else signed them in on (such as a previous +owner's phone) is signed out, and any unused code for their own device stops +working. + +- Making someone a kid removes their password, API keys and unused pairing + codes, and any collection sharing with people outside their household (their + signed-in devices stay signed in). It also removes their recovery code and any + invite of theirs that still works, and turns off sharing their listening with + the house. You can't set a password beside it: "A kid signs in without a + password. Leave the password out, or choose grown-up." +- Choosing **Only collections** means they get books through their new owner + only from the collections the owner shares with them in the player, including + any already shared with them. +- It isn't offered for an admin, and is greyed out, with the reason, for someone + who has household members of their own. A household member or a demo account + can't be the owner, and a demo account can't be a member. +- The owner's own cap and switch don't apply: you decide. ### Deleting an owner Deleting someone who has household members deletes their members too, unless you say otherwise: the delete dialog lists **Their household members**, each ticked (**Delete Evie too**). Untick a member to keep them as an independent -account. When it's done, a message says how many members were deleted and how -many kept. +account: as with **Make independent**, their devices stay signed in and any +unused code for their own device stops working. When it's done, a message says +how many members were deleted and how many kept. ## Editing someone's progress diff --git a/docs-users/listening/household.md b/docs-users/listening/household.md index 861f708..a94ccb7 100644 --- a/docs-users/listening/household.md +++ b/docs-users/listening/household.md @@ -75,7 +75,7 @@ For a kid, the shared collections are what [kids mode](kids-mode.md#what-the-chi Each person you've added has a card on the Household page: their name, *Kid* or *Grown-up* with their username, and **On this device** when they're signed in here. On it: - **What they can listen to**, with each shared collection (tap one to open it) and **Change**. -- **Kids account**: switch it on to make a grown-up a kid. The app asks first (*"Make Ellis a kids account?"*): their password and API keys are removed, and the devices they're signed in on stay signed in. Switching it off makes a kid a grown-up, who can then set a password. +- **Kids account**: switch it on to make a grown-up a kid. The app asks first (*"Make Ellis a kids account?"*): their password and API keys are removed, and the devices they're signed in on stay signed in. The server also cancels any unused pairing code or invite of theirs and their recovery code, stops them sharing their listening with the house, and ends any collection sharing between them and anyone but you. Switching it off makes a kid a grown-up, who can then set a password, and turn on sharing with the house again if they want it. - **Their devices** (below). - **Name**, with **Rename**: the name everyone on the server sees. Their username stays the same. - **Delete their account** (below), and **Remove from this device** when they're signed in here. @@ -87,6 +87,8 @@ The card lists every device the member is signed in on, each with **Sign out**. - **Set up their own device** shows a code for their own phone or tablet: they scan it with the AudioSilo app, and it signs straight in. On a kid's own device the app starts in [kids mode](kids-mode.md#on-a-kids-own-device), even though no grown-up is signed in there. - **Sign in on this device** signs them in here too, when they aren't already. +If the server's admin has turned the member's account off, neither works: the app says the account is turned off on the server and only its admin can turn it back on. The devices they're already signed in on stop working until the admin turns it back on. You can still rename them, sign them out and delete them. + ## Deleting a member, or removing them from this device These are different: From fb227f4e19451d7e10b462cb6912cb33bbb4cb71 Mon Sep 17 00:00:00 2001 From: KodeStar Date: Tue, 13 Oct 2026 18:33:55 +0100 Subject: [PATCH 03/11] Household members, pass 3: the docs follow the final player, with new shots User Guide: the Household page's final wording (the member's own view names their owner, the empty line, the count beside Add); the add dialog's pending "'s books" made on save and "Share 's books"; the member card (This device, Sign in on this device, rename here, the remove and delete copy, refusals in the app's words); switching to everything stops sharing and a kid gets one library; a member signed out on this device and Sign in again; kids mode turns links away, asks a grown-up to sign a kid in again, plays at 1x and is where a kid's account starts; a kid's Account page; the sign-out warnings for members; pairing link refusals; the car's 50 stories. Developer Docs: apiErrorKind and the codes, signInMemberHere and renewMemberHere, the add flow's useMemberBooks, the member card, DevicePeople in household/, kidsShelfMode and kidsOfflineScope, listenerSettings (1x and bedtime), the provider's read-again on a new token, connect/_layout as the only kids gate, the car's trimmed shelf, useRevokeMyDevice's one shape. Screenshots: Ellen now adds Leo (kid) and Ruth (grown-up) through POST /me/household/members and signs Leo in from his card; new household, household-device (was settings-household), household-member, the add dialog for a kid and a grown-up, phone-household; maya's household for admin/people and admin/person-member; profiles, profile-menu and the kids shots retaken. --- .../architecture/cross-repo-contract.md | 8 +- .../frontend/connect-and-addresses.md | 15 +- docs-developers/frontend/kids-mode.md | 112 ++++-- .../frontend/listening-in-the-house.md | 5 +- .../frontend/native-integrations.md | 7 +- docs-developers/frontend/player-ui.md | 3 +- docs-developers/frontend/profiles.md | 149 +++++--- docs-developers/frontend/sleep-timer.md | 7 +- docs-developers/frontend/state-and-data.md | 24 +- docs-developers/frontend/testing.md | 2 +- docs-developers/frontend/you-and-settings.md | 17 +- docs-users/admin/users-and-invites.md | 4 +- docs-users/listening/account.md | 17 +- docs-users/listening/connecting.md | 4 +- docs-users/listening/household.md | 57 ++- docs-users/listening/in-the-car.md | 2 +- docs-users/listening/kids-mode.md | 20 +- docs-users/listening/profiles.md | 7 +- docs-users/listening/settings.md | 2 +- screenshots/README.md | 71 ++-- screenshots/capture-admin.mjs | 35 +- screenshots/capture-web.mjs | 330 ++++++++++++++---- screenshots/manifest.mjs | 20 +- static/img/screenshots/admin/people.png | Bin 116965 -> 123647 bytes .../img/screenshots/admin/person-member.png | Bin 0 -> 42874 bytes .../web-player/household-add-grownup.png | Bin 0 -> 114543 bytes .../web-player/household-add-kid.png | Bin 0 -> 124381 bytes .../web-player/household-device.png | Bin 0 -> 80811 bytes .../web-player/household-member.png | Bin 0 -> 34316 bytes .../img/screenshots/web-player/household.png | Bin 0 -> 112178 bytes static/img/screenshots/web-player/kids.png | Bin 405779 -> 405609 bytes .../web-player/phone-household.png | Bin 0 -> 48061 bytes .../img/screenshots/web-player/phone-kids.png | Bin 224849 -> 232665 bytes .../screenshots/web-player/profile-menu.png | Bin 394469 -> 393026 bytes .../img/screenshots/web-player/profiles.png | Bin 39355 -> 41538 bytes .../web-player/settings-household.png | Bin 73033 -> 0 bytes 36 files changed, 682 insertions(+), 236 deletions(-) create mode 100644 static/img/screenshots/admin/person-member.png create mode 100644 static/img/screenshots/web-player/household-add-grownup.png create mode 100644 static/img/screenshots/web-player/household-add-kid.png create mode 100644 static/img/screenshots/web-player/household-device.png create mode 100644 static/img/screenshots/web-player/household-member.png create mode 100644 static/img/screenshots/web-player/household.png create mode 100644 static/img/screenshots/web-player/phone-household.png delete mode 100644 static/img/screenshots/web-player/settings-household.png diff --git a/docs-developers/architecture/cross-repo-contract.md b/docs-developers/architecture/cross-repo-contract.md index 8685967..7f776a8 100644 --- a/docs-developers/architecture/cross-repo-contract.md +++ b/docs-developers/architecture/cross-repo-contract.md @@ -600,8 +600,12 @@ member's session and pairing code `409 member_disabled`. **Client rules:** a member signed in on the owner's device is placed by `addMemberHere` (the owner stays the person listening, the default connection doesn't move); `member_kind === 'kid'` turns kids mode on by default and allows it -with no grown-up on the device; `managed_by` picks the kids shelf's collections. The -player never filters a kid's access itself. +with no grown-up on the device; `managed_by` picks the kids shelf's collections +(`kidsShelfMode`). The player never filters a kid's access itself. Refusals are read +by `ApiError.code` (`apiErrorKind` in `client.ts`, the codes above), falling back on +the status for an older server or a route without codes, and said in the app's own +words: the server's `error` message is never shown. A new `code` therefore needs a +case in `API_ERROR_CODES` and its words in the player, or it reads by its status. **Old clients:** a shipped player ignores the new `user` fields and sees a member as an ordinary account; a kid signed in to an old player is still limited by the diff --git a/docs-developers/frontend/connect-and-addresses.md b/docs-developers/frontend/connect-and-addresses.md index 82dd46c..e618591 100644 --- a/docs-developers/frontend/connect-and-addresses.md +++ b/docs-developers/frontend/connect-and-addresses.md @@ -61,7 +61,20 @@ the server's own name when the flow knows it (the probe, the redeem payload's addresses the link (`linkAddresses`) and the answer taught, merged there once (`mergeAddresses`). The device's **first** connection then shows `/connect/ready`; an added server goes straight back with -`leaveOnboarding()`. +`leaveOnboarding()`; and whenever the person listening is in kids mode afterwards (a +kid's account signing in, below) it goes straight to `/kids` instead, with no "Your +library is ready.". A link's exchange that fails is said in the app's words, never the +server's (`apiErrorKind`): 429 "Too many tries. Wait a few minutes and try again.", a +refused link (expired, used up, unknown, malformed) "This pairing link has expired or +was already used...", a 5xx "The server couldn't finish pairing...", and no answer +"Could not reach the server to finish pairing.". + +**Kids mode never runs the flow.** `src/app/connect/_layout.tsx` is the one gate: while +a kids profile listens, no step of the flow mounts (so a link's token is never spent), +the child is sent back to `/kids`, and a link opened in kids mode gets the toast "Ask a +grown-up to open this link". Only a grown-up's Add someone on a device with no grown-up +gets through (`kidsConnectAllowed`); the layout also waits until both the session and +the profiles have been read. See [Kids mode](kids-mode.md#the-route-and-its-guard). A kid's account (`user.member_kind === 'kid'`, a household member) placed for the first time on this device starts in kids mode (`kidsOnForKidAccount`, after the diff --git a/docs-developers/frontend/kids-mode.md b/docs-developers/frontend/kids-mode.md index 6356936..133ad7a 100644 --- a/docs-developers/frontend/kids-mode.md +++ b/docs-developers/frontend/kids-mode.md @@ -1,6 +1,6 @@ --- title: Kids mode -description: "Kids mode (Phase 8, with kid household members since 8b-1): a property of a profile. Kids mode by default for a kid's account, the /kids route and its guard, the stories it shows (the owner's shared collections, else one library), the kids player, the Grown-ups hold, the car, and bedtime through the auto sleep controller." +description: "Kids mode (Phase 8, with kid household members since 8b-1): a property of a profile. Kids mode by default for a kid's account, the /kids route and its guard, connect/_layout as the one gate between kids mode and the connect flow, the stories it shows (kidsShelfMode: the owner's shared collections, else one library), a kid signed out, the kids player at 1×, the Grown-ups hold, the car, and bedtime through listenerSettings." --- Kids mode is a **property of a [profile](profiles.md)** (`Profile.kids`), set by an @@ -32,8 +32,10 @@ signs in beside the owner, and `finishConnect` does the same at a kid account's **first** placement on a device (`kidsOnForKidAccount`: a new connection, not a re-pair or a sign-in after a server reset, whose profile has no kids settings yet and holds only kid accounts), which covers the kid's own device signing in from its QR. A -grown-up's profile that a kid's sign-in joined is never put in kids mode. On a server -without household members, `member_kind` is absent and nothing changes. `KidsLibraryPicker` offers the libraries the profile's +grown-up's profile that a kid's sign-in joined is never put in kids mode. A kid's first +sign-in then goes straight to `/kids` (`finishConnect` dismisses to `KIDS_PATH` whenever +the person listening is in kids mode afterwards): never "Your library is ready.". On a +server without household members, `member_kind` is absent and nothing changes. `KidsLibraryPicker` offers the libraries the profile's connections list, named with their server when the profile has several; no pick means the first one listed. @@ -48,20 +50,30 @@ tabs. A kids profile never gets the shell: - **`KidsRouteGuard`**, mounted once by the root layout so it covers the root routes too, sends a kids profile to `/kids` from anywhere it may not be. `kidsAllowsPath` allows only `/kids`, `/profiles` (leaving kids mode goes through it, PIN rules - included) and `/connect` (the child's own sign-in renewed, or "Add someone" when it is - allowed, below); the tabs, the full player, - the end credits, the year story and any deep link into them go back. It uses + included) and `/connect`, which keeps kids mode out itself (below); the tabs, the full + player, the end credits, the year story and any deep link into them go back. It uses `dismissTo('/kids')`, so a `/kids` already in the root stack is popped back to rather than stacked twice. - On Android the back button does nothing on `/kids` while the shell sits under it; with nothing under it, it leaves the app as usual. +- **The connect flow's gate is `src/app/connect/_layout.tsx`, the only one** (8b-1, + D1). While a kids profile listens it runs **none** of the flow (no step mounts, so a + pairing link's token is never exchanged and no server is reconnected or forgotten) + and `dismissTo`s `/kids` once the root navigator is ready. When kids mode was already + on as the flow opened (a link: `audiosilo://connect?token=`, a QR scanned elsewhere, + `/web/connect`), a toast says "Ask a grown-up to open this link" ("A grown-up holds + Grown-ups first, then opens the link again."); kids mode turning on during the flow + is a kid's own first sign-in, which `finishConnect` has already sent to `/kids`, so no + toast. Nothing of the flow runs until both the session and the profiles have been + read (before that, kids mode can't be told and a token would be spent on a kids + device). The one exception is `kidsConnectAllowed()`: "Add someone" opened by a + grown-up from Who's listening? on a device with **no** grown-up (`openAddSomeone` + sets the intent, `endConnectFlow` clears it when the flow unmounts). - **Nobody new signs in from kids mode** while a grown-up is on the device - (`mayAddPerson`, `src/components/profiles/connect-intent.ts`): `finishConnect` refuses - "Add someone", a new server and the demo ("Choose a grown-up before adding someone."), - and Who's listening? disables **Add someone** with that hint, so a child can't leave - kids mode through a new profile; they choose the grown-up first, PIN and all. A - re-pair of a connection the device has (the child's own sign-in renewed, through the - reconnect banner on the kids screen) keeps its person and is allowed. With no grown-up + (`mayAddPerson`, `src/components/profiles/connect-intent.ts`): `finishConnect` and + `addMemberHere` refuse ("Choose a grown-up before adding someone."), and Who's + listening? disables **Add someone** with that hint, so a child can't leave kids mode + through a new profile; they choose the grown-up first, PIN and all. With no grown-up left, Add someone stays open, the only way out. Nothing is added while the profiles couldn't be read. @@ -87,6 +99,14 @@ The rules are pure in `kids-model.ts`, read through `useKidsStories()`: shelf says "Ask a grown-up to add stories." The client can't see `member_access` (it isn't on `/me`), so an `all` member whose owner also shares collections with them sees those collections; one with none sees the one-library view. +- **Which one** is `kidsShelfMode(user, hasCollections, collections)`: `collections` + when the owner shares at least one, `library` for an account nobody manages, a + server without `collections` or a member with none shared, and `unknown` while a + member's answers are still out. The Household page reads the same through + `useKidsSharedShelf(profile)` and hides the kids **Library** row unless the shelf is + one library (also while the kid is signed out or the collections can't be read). + When an owner switches a member to `all`, the player sends `collection_ids: []` + (`collectionIdsFor`), so the shares stop and the kid's shelf is one library again. - **The library** (`kidsLibrary`), for everyone else (an account nobody manages, a Phase 8 account the admin limited, an older server, a member with no shared collection): the pick when it is on that connection and still listed by `GET @@ -96,15 +116,30 @@ The rules are pure in `kids-model.ts`, read through `useKidsStories()`: played first, then every other book of the library in the server's order, finished ones included. - **Offline** (`kidsDownloads`): when the server can't be reached, the finished - downloads of that connection, newest first, in the library `kidsOfflineLibraryId` - names: the one shown when known, else (`GET /libraries` unreadable) the profile's pick - on this connection, else every library, so the grown-ups' downloads of another library - on the same account don't show. + downloads of that connection, newest first, in the library `kidsOfflineScope(mode, + library, connectionId, kids)` names: for a one-library shelf the one shown when known, + else (`GET /libraries` unreadable) the profile's pick on this connection, else every + library, so the grown-ups' downloads of another library on the same account don't + show; for a shelf of collections (or one not known yet) every library, since the + collections can span libraries and the server already limits the account. The whole page is **one virtualised list** (`KidsStoriesList`, a FlashList with the -greeting, the [reconnect banner](connect-and-addresses.md), the bedtime card and the -player as its header), paging through the library as it scrolls. Loading, empty, error -and offline states each have their own words. A tile starts its story with +greeting, the bedtime card and the player as its header), paging through the library +as it scrolls. Loading, empty, error and offline states each have their own words. + +**A kid signed out** (their connection's `needsReconnect`: a revoked session, a deleted +account, a server reset) never gets the shell's reconnect banner, whose sign-in opens +the connect flow. With stories still to show, `SignedOutNotice` puts a plain line over +them, "Ask a grown-up to sign Leo in again." (`askToSignIn`; "...to sign you in again" +without a name); with none, the list's `signed-out` state says "Signed out of +<server>" with the same line and no Try again. A story that won't start says it too. +The grown-up signs the kid in again from the Household page's **Sign in again** +(`SignInAgainRow`: for the owner's own member, `memberSession` then `renewMemberHere`, +which only swaps the token, keeping the connection id, profile, PIN and kids settings; +anyone else, or after a server reset, the connect flow's reconnect). `ApiProvider` then +reads the connection's failed queries and its `server`, `libraries` and `collections` +lists again once its token changes (`useReadAgainOnSignIn`, `readAgainOnSignIn`), so the +shelf recovers without a reload. A tile starts its story with `startBookInPlace` on every layout (kids mode never opens the full player; the story already loaded only resumes), and the list scrolls back up to the player (`scrollToOffset`). @@ -112,7 +147,10 @@ already loaded only resumes), and the list scrolls back up to the player **`KidsPlayer`** replaces the full player: cover, title, chapter and minutes left, a simple progress line through the chapter (no scrubbing; the view's one pink thing), and four big controls, previous chapter, back 30 s (`KIDS_BACK_SECONDS`), play/pause and -next chapter. No speed, sleep, bookmarks, Up next or sharing. It is laid out by the +next chapter. No speed, sleep, bookmarks, Up next or sharing. A story starts at 1× unless it +has a speed saved for the kid: the default speed comes from `listenerSettings()`, which +gives a kids profile `defaultRate: 1` (the device's default speed is a grown-up's +setting, and kids mode has no control to undo it). It is laid out by the measured width (`kidsPlayerLayout`): the cover beside the controls where both fit at the big sizes, the big controls under the cover where only they do, else the phone's sizes, never under the 64 pt kids minimum. @@ -129,21 +167,28 @@ A kids profile's CarPlay and Android Auto snapshot is the kids shelf only (`kidsCarBooks` in `src/car/car-model.ts`, built by `car-controller.ts` without React from the same reads as `useKidsStories`: the owner's shared collections, else the one library, else the account's downloads): the **Library** tab holds the shelf, titled -"Your stories", and **Continue listening** the shelf's stories in progress; no Up next -and no Downloads tab, nothing else the account could reach. It is rebuilt when kids +"Your stories", trimmed to `CAR_TAB_LIMITS.library` (50) with each story once before +any cover is fetched (a shelf of shared collections can hold far more), and **Continue +listening** the shelf's stories in progress (any story on the shelf, not only the first +50); no Up next and no Downloads tab, nothing else the account could reach. It is rebuilt when kids mode turns on or off. See [Native integrations](native-integrations.md#the-car-snapshot-srccar). -## Bedtime (`src/playback/auto-sleep-controller.ts`) +## Bedtime and speed (`src/playback/listener-settings.ts`) -Bedtime reuses the auto sleep controller, made profile-aware: +`listenerSettings()` is the one read of the playback settings **of whoever listens** +(pure `listenerSettingsFor(kids, settings)`): the default speed and the auto sleep rule. +The playback store (`playBook`), the car's native start specs and the auto sleep +controller (`src/playback/auto-sleep-controller.ts`) all read it; a screen showing the +default speed uses `useListenerDefaultRate()`. +- **Speed**: 1× for a kids profile, else the device's default speed. - `isBedtime(bedtime, now)`: from the profile's bedtime until `KIDS_BEDTIME_UNTIL` (06:00) local time, across midnight. -- `effectiveAutoSleepConfig()` gives the rule that applies now, and `decideAutoSleep` - applies it as it does the setting, with the same session anti-nag memory: inside the - window, every story that starts or resumes arms a duration timer of - `KIDS_BEDTIME_MINUTES` (20) with the normal fade, unless a timer on that book was +- The auto sleep rule for a kids profile is its bedtime, always on, and + `decideAutoSleep` applies it as it does the setting, with the same session anti-nag + memory: inside the window, every story that starts or resumes arms a duration timer + of `KIDS_BEDTIME_MINUTES` (20) with the normal fade, unless a timer on that book was cancelled this session. - While a kids profile is active, its bedtime rule **replaces** the device-wide auto sleep setting (a grown-up's "end of chapter from 22:00" is not a child's bedtime), and @@ -158,8 +203,11 @@ running timer has left. ## Testing -`kids-model.test.ts` (the rules above, the collections shelf included), `use-kids.test.tsx`, `kids-screen.test.tsx`, -`kids-player.test.tsx`, `grown-ups-button.test.tsx`, `kids-route-guard.test.tsx` and -`kids-gate.test.tsx` in `src/components/kids/`; the bedtime rule's regression tests in -`src/playback/auto-sleep-controller.test.ts`. That a tapped story starts in place and +`kids-model.test.ts` (the rules above, the collections shelf and `kidsShelfMode` +included), `use-kids.test.tsx`, `kids-screen.test.tsx`, `kids-player.test.tsx`, +`grown-ups-button.test.tsx`, `kids-route-guard.test.tsx` and `kids-gate.test.tsx` in +`src/components/kids/`; the connect gate in `src/components/connect/connect-layout.test.tsx`; +`src/playback/listener-settings.test.ts` (1× and the bedtime rule) and the bedtime +regression tests in `src/playback/auto-sleep-controller.test.ts`; the read-again on a new +token in `src/api/provider.test.tsx`; the car's trimmed shelf in `src/car/car-model.test.ts`. That a tapped story starts in place and never opens the full player is in `kids-screen.test.tsx`. diff --git a/docs-developers/frontend/listening-in-the-house.md b/docs-developers/frontend/listening-in-the-house.md index c3aa0da..9f0a2da 100644 --- a/docs-developers/frontend/listening-in-the-house.md +++ b/docs-developers/frontend/listening-in-the-house.md @@ -117,7 +117,10 @@ pane before Phase 8b-1) renders one `HouseSharingRow` per connection of the acti profile whose server has `household`, under a "Listening in the house" heading (a note when none of them offers it, once every server has answered). A kid household member never shares and never sees the house: the server answers `{ sharing: false, listening: [] -}` and refuses its `PUT` (`403 kid_account`). See +}` and refuses its `PUT` (`403 kid_account`). `HouseSharingRow` shows a kid's account +(`isKidAccount`) the switch off and disabled, with "A kid's account can't share what it +listens to." in place of the privacy sentence, and a `kid_account` refusal reads the +same, never the demo account's words. See [Profiles](profiles.md#visible-and-background-connections). ## Testing diff --git a/docs-developers/frontend/native-integrations.md b/docs-developers/frontend/native-integrations.md index f551a8f..70c886e 100644 --- a/docs-developers/frontend/native-integrations.md +++ b/docs-developers/frontend/native-integrations.md @@ -70,8 +70,11 @@ protection), so a car shows its lists at once, before or without JS. - **A kids profile** (player redesign 8b-1) gets the kids shelf only (`kidsCarBooks`): the `library` tab holds the shelf as [kids mode](kids-mode.md#in-the-car) shows it (the owner's shared collections, else the one library, else the account's - downloads), titled "Your stories" through the snapshot input's `titles`, and - `continue` the shelf's stories in progress; `upnext` and `downloads` are `null`, so + downloads), titled "Your stories" through the snapshot input's `titles`, each story + once and trimmed to `CAR_TAB_LIMITS.library` (50) in `kidsCarBooks` itself, before + the controller fetches a cover for every book a tab holds (a shelf of shared + collections can hold a thousand), and `continue` the shelf's stories in progress + (found on the whole shelf, not only its first 50); `upnext` and `downloads` are `null`, so those tabs are left out (`CarSnapshotInput.books` is `Record`, a `null` tab omitted). The controller builds it without React from the same reads as `useKidsStories` and rebuilds the snapshot when kids mode turns on or off. diff --git a/docs-developers/frontend/player-ui.md b/docs-developers/frontend/player-ui.md index f3421f8..33639ef 100644 --- a/docs-developers/frontend/player-ui.md +++ b/docs-developers/frontend/player-ui.md @@ -185,7 +185,8 @@ be told from listening. One rule for how every screen says the time left in a book (frontend#50: the speed is saved per book, so the time left must use it too): **wall-clock** time at **that book's** speed - the loaded book's `rate`, else its saved `playback_speed`, else the -`defaultRate` setting (`bookSpeed`) - naming the speed except at 1× ("22h 27m left +listener's default speed (`bookSpeed`; `useListenerDefaultRate()`, the `defaultRate` +setting, or 1× while a kids profile listens) - naming the speed except at 1× ("22h 27m left at 1.25×", "22h 27m left"). `timeLeft`, `formatTimeLeft` and `timeLeftLabel` are the pure pieces. diff --git a/docs-developers/frontend/profiles.md b/docs-developers/frontend/profiles.md index 5aefbff..184fba9 100644 --- a/docs-developers/frontend/profiles.md +++ b/docs-developers/frontend/profiles.md @@ -1,6 +1,6 @@ --- title: Profiles -description: "Profiles (Phase 8): a person on this device as a group of connections. The profiles store and document, the consistency rules, visible versus background connections, switching and the play rule, Who's listening?, the device-local PIN, links to another person's book, the Household page, and signing a household member in beside the owner (addMemberHere)." +description: "Profiles (Phase 8): a person on this device as a group of connections. The profiles store and document, the consistency rules, visible versus background connections, switching and the play rule, Who's listening?, the device-local PIN, links to another person's book, the Household page (members, the add dialog, apiErrorKind), and signing a household member in beside the owner (addMemberHere, signInMemberHere, renewMemberHere)." --- A **profile is a person on this device**: a device-local group of connections, at @@ -244,53 +244,96 @@ You hub's `household` segment (between Settings and Account, `embedded`, since t hub's large title already says Household) and the pushed page `/household` (`src/app/(app)/(home,library,search,offline,me)/household.tsx`, in the array group like `/settings`, so back returns where the listener was and a cold link opens it in -Home). Every way in goes through `openHousehold()` -(`src/components/household/open-household.ts`, `householdHref()` in `paths.ts`): the -profile menu, "Who's listening?" > Manage household, the link at the end of Settings' -App group, Home's house row and the web palette. `/settings?section=household` -(`isHouseholdSection`) **replaces** itself with `/household`, so an old link lands on -the page and back doesn't bounce into the redirect. +Home). Every way in goes through `openHousehold()` (`src/lib/open.ts`, `householdHref()` +in `paths.ts`; a no-op on the page already): the profile menu, "Who's listening?" > +Manage household, the link at the end of Settings' App group, Home's house row and the +web palette. `/settings?section=household` (`isHouseholdSection`) **replaces** itself +with `/household`, so an old link lands on the page and back doesn't bounce into the +redirect. The page is a plain column of three parts: -- **`DevicePeople`** (`src/components/profiles/household-pane.tsx`, "On this - device"): a card per person (switch, rename, PIN set/change/remove, kids mode with - `KidsLibraryPicker` and the bedtime, **Remove from this device**), **Who's - listening?** and **Sign in someone who already has an account** (the connect flow's - Add someone, `ADD_SOMEONE_HREF`), and the **Ask who's listening when the app - opens** switch. +- **`DevicePeople`** (`src/components/household/device-people.tsx`, "On this + device"): a card per person (`PersonHeader`, Switch, rename, PIN set/change/remove, + kids mode with the bedtime and, while the shelf is one library + (`useKidsSharedShelf`), `KidsLibraryPicker`, **Remove from this device**), **Who's + listening?** and **Sign in someone who already has an account** (`openAddSomeone`, + the connect flow's Add someone, `ADD_SOMEONE_HREF`), and the **Ask who's listening + when the app opens** switch. A person with a connection whose sign-in ended + (`needsReconnect`) gets a **Signed out** row with **Sign in again** + (`SignInAgainRow`): when that connection is a member the person listening owns + (`memberOwnerHere`) and its token is dead (not a server reset), it is + `useMemberSession` then `renewMemberHere`, at once and with no password; otherwise + `reconnectConnection`, the reconnect banner's sign-in, which keeps the person. + Refusals say why in the app's words (`sessionRefusalText`: too many tries, a disabled + member, one no longer in the household). - **`HouseholdMembers`** (`src/components/household/members-section.tsx`): per connection of the person listening whose server has `household_members`, the members list (`useHouseholdMembers`), a `MemberCard` each and **Add someone to your - household** (`AddMemberDialog`), or the explanation for the list's `reason` (or - `at_cap`, from `members.length >= max`). The pure rules are `household-model.ts` - (the name rule, the default access per kind, the error mapping). -- **`HouseSharing`**: the per-server house sharing switches + household** with "N of M added" (`AddMemberDialog`), or one line for the list's + `reason` (`addBlock`, which adds `at_cap` from `members.length >= max`). A member's own + page names their owner instead (`managerName(connection.user)`: "Adam manages your + account.", "Only Adam can add people or change what you can listen to."). The pure + rules are `household-model.ts` (the name rule, the default access per kind, + `collectionIdsFor`, the error mapping, `memberConnection`, `confirmsName`). +- **`HouseSharing`** (`house-sharing.tsx`): the per-server house sharing switches ([Listening in the house](listening-in-the-house.md)). -**Adding a member** (`AddMemberDialog`): a kid is `addMember({ …, sign_in_here: true -})`, then `addMemberHere(session, { name, kids: true })`, then the offer to set the -owner's PIN when their profile has none; a grown-up is `addMember` then -`memberPairing` and the QR, with "They'll also use this phone" (`memberSession` then -`addMemberHere(…, { kids: false })`). The access default follows the Kids switch -(kid: `collections`, grown-up: `all`) until the owner picks one. A `MemberCard` -carries what they can listen to (and its change dialog), the Kids switch (`PATCH -kind`, confirmed when it makes a kid), their devices (`useMemberDevices`, each with -sign out), "Set up their own device", "Sign in on this device" -(`memberSignedInHere` is asked first, so no session is minted that `addMemberHere` -would refuse), rename, and "Delete their account" (confirmed twice, the second by -typing the name), kept apart from **Remove from this device**. +**Adding a member** (`AddMemberDialog`): the form is the name (cut to 40 code points, +as the server counts them), the Kids switch and `AccessPicker`; the access default +follows the Kids switch (kid: `collections`, grown-up: `all`) until the owner picks +one. "Create <Name>'s books" creates nothing yet: it adds a pending, ticked row ("New +collection, created when you save"); when the owner already has a collection of that +name the button is "Share <Name>'s books" and ticks that one instead. On save, +`useMemberBooks().run(booksName, write)` creates the collection first, runs the write +with its id in `collection_ids`, and deletes it again when the write is **refused** (a +4xx or a `CapabilityError`); with no answer, or a 5xx, the write may have landed, so it +stays and the message says so (`booksRunText`). Then: + +- a kid is `addMember({ …, sign_in_here: true })`, and its `session` goes to + `signInMemberHere`, then the offer to set the owner's PIN when their profile has none; +- a grown-up is `addMember`, then `MemberPairing` (`memberPairing`) shows the QR, with + "They'll also use this phone" (`useSignInHere`: `memberSession` then + `addMemberHere`). + +**A `MemberCard`** carries what they can listen to (and its change dialog, the same +picker; moving `collections` to `all` sends `collection_ids: []` and says first that it +stops sharing), the Kids switch (`PATCH kind`, confirmed when it makes a kid; when the +member is signed in here, their connection's `user.member_kind` is updated at once so +their password card and kids mode follow), their devices (`useMemberDevices`, each with +Sign out; their session on **this** device is marked "This device", found by reading +the member's own `GET /me/devices` with their token here, the only list that marks +`current`, and signing that one out marks their connection `needsReconnect` at once with +`markNeedsReconnect`), "Set up their own device" (`MemberPairing`), "Sign in on this +device" while they aren't here, Rename (the server's name and, when they are here, +their profile's), and "Delete their account" (`ConfirmDialog`, then typing the name, +`confirmsName`), kept apart from **Remove from this device** (shown while they are +here). + +**Refusals** are told apart by `apiErrorKind(e)` (`src/api/client.ts`): the server's +`code` when it is one the app knows (`member_limit`, `username_taken`, `invalid_name`, +`invalid_collections`, `feature_off`, `not_allowed`, `member_cannot_manage`, +`member_disabled`, `kid_account`, `demo_account`), else the status (`too_many` 429, +`not_found` 404, `forbidden` 403, `invalid` 400, `conflict` 409, `failed` for anything +else or no answer). Screens map that to their own words (`writeError` / +`writeErrorText` for the member writes, `hereFailure` for signing a member in here) and +never show `ApiError.message`; a 429 reads "Too many tries. Wait a few minutes and try +again." (`common.tooManyTries`). **Remove from this device** (`useRemovePerson`) signs the device out of each of the person's servers through the same sign-out as an Account page (their book stops with its place saved, queued saves land, the token is revoked, their downloads here go), then drops their PIN and profile. It warns first where they have no password on a -server (they would need a new invite). Removing the person listening leaves nobody -chosen. +server (they would need a new invite); for a household member (`managed_by`) it says +instead that the account stays and who can sign them in here again. Every connection +removed, by any path through `removeConnection`, has the other connections to that +server read their household again (`onConnectionRemoved` in `hooks.ts`, +`invalidateHousehold`), so the owner's page stops showing this device under the +member. Removing the person listening leaves nobody chosen. ## Signing a member in here (`addMemberHere`) -`addMemberHere(session, { name, kids })` (`src/components/connect/add-member-here.ts`) +`addMemberHere(session, { name })` (`src/components/connect/add-member-here.ts`) is the variant of `finishConnect` that signs a household member in **beside** the owner, with no navigation: @@ -303,22 +346,44 @@ owner, with no navigation: - `placeConnection(id, { newProfile: true, keepActive: true, name })`: a profile of its own named as the owner typed, and **never** `switchProfile`, so the owner stays the person listening and their book plays on; -- for a kid, `setProfileKids` with the default bedtime. - -It refuses, changing nothing, with `AddMemberHereError` (`already_here`) when the -device already holds that user on that server (`memberSignedInHere`; signing them in -again would replace the connection, PIN and all), and with an `Error` while the person -listening is in kids mode with a grown-up on the device (`mayAddPerson`), when the -profiles can't be read, or when the device has no connection to that server. It -resolves to `{ connectionId, profileId }`. +- `kidsOnForKidAccount`: a kid's account (`member_kind`) starts in kids mode with the + default bedtime. + +It refuses, changing nothing, with `AddMemberHereError` and a code: `already_here` +when the device already holds that user on that server (`memberSignedInHere`; signing +them in again would replace the connection, PIN and all), `choose_adult` while the +person listening is in kids mode with a grown-up on the device (`mayAddPerson`), and +`not_ready` when the profiles can't be read or the device has no connection to that +server. It resolves to `{ connectionId, profileId }`. + +Around it, in `src/components/household/use-sign-in-here.ts`: + +- **`signInMemberHere(mint, member, serverId)`** asks `memberSignedInHere` first (so no + session is minted that `addMemberHere` would refuse), then `mint()` (the add's + `session`, or `memberSession`) and `addMemberHere`. It never throws: it resolves to + `{ status: 'done' | 'already' }` or `{ status: 'failed', reason? }`, the reason being + `addMemberHere`'s code or the server's refusal (`too_many`, `member_disabled`, + `not_found`). `useSignInHere(owner)` wraps it with `useMemberSession` and a busy flag. +- **`renewMemberHere(session)`** (in `add-member-here.ts`) signs in again a member this + device already holds (their sign-in ended): only the token changes, through the same + store as the add, so the connection keeps its id, its person's profile, PIN and kids + settings, and the person listening and the default connection stay. `not_ready` when + the device doesn't hold that member. + +`ApiProvider` (`src/api/provider.tsx`) reads a connection's answers again whenever its +token changes (`useReadAgainOnSignIn`): whatever failed while it was signed out, and its +`server`, `libraries` and `collections` lists (`readAgainOnSignIn`). It runs in the +provider's effect, after every screen's own, so the queries already hold the new client. ## Testing `src/stores/profiles.test.ts` (parsing, `reconcileProfiles`, placement, `mustChoose`, switching), `src/lib/profile-pin.test.ts`, `sha256.test.ts`, `random.test.ts`, and in `src/components/profiles/`: `profiles-model.test.ts`, `profiles-screen.test.tsx`, -`pin-dialog.test.tsx`, `household-pane.test.tsx`; `add-member-here.test.ts` in -`src/components/connect/`; the gate in +`pin-dialog.test.tsx`; in `src/components/household/`: `household-page.test.tsx`, +`members-section.test.tsx`, `member-card.test.tsx`, `household-model.test.ts` and +`use-sign-in-here.test.ts`; `add-member-here.test.ts` in `src/components/connect/`; +`src/api/provider.test.tsx` (read again on a new token); the gate in `src/components/shell/auth-gate.test.tsx` and the menu in `profile-menu.test.tsx`; the "no screen lists another person's servers" checks beside each screen that reads connections. diff --git a/docs-developers/frontend/sleep-timer.md b/docs-developers/frontend/sleep-timer.md index 424a4d8..243c575 100644 --- a/docs-developers/frontend/sleep-timer.md +++ b/docs-developers/frontend/sleep-timer.md @@ -383,9 +383,10 @@ The nightly auto-arm is split into a pure decision function and a framework-free controller, so all the policy is unit-tested and none of it lives in a component. - **`decideAutoSleep(input)`** takes the rule that applies (`AutoSleepConfig`: - `enabled`, `from`, `until`, `type`; the controller's `effectiveAutoSleepConfig()` - passes the four `autoSleep*` settings, or a kids profile's bedtime rule while one is - active, see [Kids mode](kids-mode.md#bedtime-srcplaybackauto-sleep-controllerts)), + `enabled`, `from`, `until`, `type`; the controller passes `listenerSettings().autoSleep` + from `src/playback/listener-settings.ts`: the four `autoSleep*` settings, or a kids + profile's bedtime rule while one is active, see + [Kids mode](kids-mode.md#bedtime-and-speed-srcplaybacklistener-settingsts)), `now`, the `bookKey` and the per-book memory, and returns `{arm:'none'} | {arm:'chapter'} | {arm:'duration', minutes}`. It bails when the feature is off, when the memory says this book is blocked (`canAutoSleepArm`), diff --git a/docs-developers/frontend/state-and-data.md b/docs-developers/frontend/state-and-data.md index 76d7813..ffb9b0f 100644 --- a/docs-developers/frontend/state-and-data.md +++ b/docs-developers/frontend/state-and-data.md @@ -368,11 +368,25 @@ gains `household_members`. `useMemberDevices(id)` (queries gated on `useCapability('household_members')` with `skipToken`), and the mutations `useAddMember`, `useMemberSession`, `useMemberPairing`, `useRevokeMemberDevice`, `useUpdateMember` and `useDeleteMember` -(each rejects with `CapabilityError` while the flag is off or unknown). They refresh -the members list, that member's devices and, when a sign-in on this device appears or -goes, the caller's own devices (whose rows carry `member_sign_ins`). -`useRevokeMyDevice` takes `number | { id, alsoMembers }` (`RevokeMyDeviceVars`), -sending `alsoMembers` only to a server with the flag. +(each rejects with `CapabilityError` while the flag is off or unknown; never hand that +to `noteError`). They refresh the members list, that member's devices and, when a +sign-in on this device appears or goes, the caller's own devices (whose rows carry +`member_sign_ins`; `invalidateHousehold`); a refused write reads the list again (a 404 +means the member is gone). A member change that touches sharing (`collection_ids`, a +delete) also reads the collections again, since each lists the people it is shared +with. `useRevokeMyDevice` takes `{ id, alsoMembers? }` (`RevokeMyDeviceVars`), sending +`alsoMembers` only to a server with the flag. Every connection removed from the device +(`removeConnection`, by any path) has the other connections to that server read their +household again (`onConnectionRemoved` in `hooks.ts`). + +- **Refusals are read by kind**, never by message: `apiErrorKind(e)` in `client.ts` + gives the server's `code` when the app knows it (`ApiError.code`, the + `API_ERROR_CODES` list: `member_limit`, `username_taken`, `invalid_name`, + `invalid_collections`, `feature_off`, `not_allowed`, `member_cannot_manage`, + `member_disabled`, `kid_account`, `demo_account`), else the status's kind + (`too_many` 429, `not_found` 404, `forbidden` 403, `invalid` 400, `conflict` 409, + `failed`), so an older server without codes still reads sensibly. Screens say it in + their own words; `ApiError.message` (the server's English) is never shown. - **The members list never refuses an ordinary session**: a demo or member caller gets `allowed: false` with a `reason`, and "at the cap" is the client's own diff --git a/docs-developers/frontend/testing.md b/docs-developers/frontend/testing.md index 6d283ac..bfcf68e 100644 --- a/docs-developers/frontend/testing.md +++ b/docs-developers/frontend/testing.md @@ -326,7 +326,7 @@ Co-located suites exist for: | Config plugins | `plugins/withCarPlay.test.ts` (the scene manifest, the AppDelegate rewrite over an SDK 56 fixture, the default-on entitlement and its `AUDIOSILO_CARPLAY=0` opt-out), `plugins/withCast.test.ts` (the Cast options, the Android expanded controller, both plugins in `app.json` order over the SDK 56 AppDelegate), `plugins/withWidgetsNoPush.test.ts` | | Cast | `src/cast/cast-media.test.ts` (the pure casting rules), `cast-token.test.ts`, `cast-prepare.test.ts`, `cast-service.test.ts`, `routed-service.test.ts` (the route switch), `cast-session.test.ts`, `cast-store.test.ts`, `cast-button-model.test.ts`, `cast-button.test.tsx`, `driver.native.test.ts`, `driver.web.test.ts` (one tab owns a session), `sdk-shapes.test.ts`, `web-sender.test.ts` | | Listening in the house | `src/components/house/house-model.test.ts`, `house-row.test.tsx`, `in-the-house-card.test.tsx`, `house-sharing-row.test.tsx`; `src/components/series/portrait.test.tsx` (the avatar and its ring); `src/api/hooks-household.test.tsx` (gating and polling) | -| Profiles | `src/stores/profiles.test.ts`, `src/lib/profile-pin.test.ts`, `sha256.test.ts`, `random.test.ts`; `src/components/profiles/profiles-model.test.ts`, `profiles-screen.test.tsx`, `pin-dialog.test.tsx`, `household-pane.test.tsx`; `src/components/shell/auth-gate.test.tsx`, `profile-menu.test.tsx` | +| Profiles | `src/stores/profiles.test.ts`, `src/lib/profile-pin.test.ts`, `sha256.test.ts`, `random.test.ts`; `src/components/profiles/profiles-model.test.ts`, `profiles-screen.test.tsx`, `pin-dialog.test.tsx`; `src/components/household/household-page.test.tsx`, `members-section.test.tsx`, `member-card.test.tsx`, `household-model.test.ts`, `use-sign-in-here.test.ts`; `src/components/shell/auth-gate.test.tsx`, `profile-menu.test.tsx` | | Kids mode | `src/components/kids/kids-model.test.ts`, `use-kids.test.tsx`, `kids-screen.test.tsx`, `kids-player.test.tsx`, `grown-ups-button.test.tsx`, `kids-route-guard.test.tsx`, `kids-gate.test.tsx`; the bedtime rule in `src/playback/auto-sleep-controller.test.ts` | | Connection identity | `src/lib/connection-id.test.ts`, `src/lib/identity-migration.test.ts` (every store before and after, the abort paths before the plan, forward-only recovery after it (a failed file move or document write finished by the next try, never reversed), resumption, idempotency, the native file moves, the web keeping its files under the legacy id), `src/lib/storage-migration.test.ts` (readers held while a stored plan is retried, and released after the five tries) | | Stores | `src/stores/session.test.ts`, `settings.test.ts`, `series-orderings.test.ts`, `library-selection.test.ts`, `search.test.ts` | diff --git a/docs-developers/frontend/you-and-settings.md b/docs-developers/frontend/you-and-settings.md index d919162..9eefaf1 100644 --- a/docs-developers/frontend/you-and-settings.md +++ b/docs-developers/frontend/you-and-settings.md @@ -237,6 +237,17 @@ gone, or the server didn't answer). **A lost phone takes the household with it.** When another device's row carries `member_sign_ins` (capability `household_members`: the listener's household members signed in on that device, `memberSignIns` in `account-model.ts`), the confirm adds -"Also sign out Evie and Ellis on that device" (`joinNames`), **ticked**, fresh for -every device. Confirming sends `useRevokeMyDevice({ id, alsoMembers })` (the plain id -when there are no members, today's call), and the toast counts `members_signed_out`. +"Also sign out Evie and Ellis on that device" (`joinList` in `src/lib/names.ts`), +**ticked**, fresh for every device. Confirming sends `useRevokeMyDevice({ id, +alsoMembers })` (`alsoMembers` false when there are no members), and the toast counts +`members_signed_out`. + +**A household member's account page** (`isKidAccount`, `managerName` in +`src/lib/account.ts`): the page reads `/me` when it opens (`useFreshUser`), so a +`member_kind` the owner changed since sign-in shows. A kid's account gets a Password +card with no button ("A kid's account has no password. Adam signs it in on a new +device."), no API keys section, and its pairing refused in the app's words (`403 +kid_account`: "A kid's account can't pair a device..."); a grown-up member's Password +card names who set the account up. The sign-out warning for a member without a +password names their owner instead of an invite (`signOutWarning`), and a kid's house +sharing row is off and disabled (`house.share.kid`). diff --git a/docs-users/admin/users-and-invites.md b/docs-users/admin/users-and-invites.md index 653a74e..bbd57f1 100644 --- a/docs-users/admin/users-and-invites.md +++ b/docs-users/admin/users-and-invites.md @@ -10,7 +10,7 @@ they sign in. It has four sections: **People**, **Invites**, **Shares** (see ## The People page -![The People page](/img/screenshots/admin/people.png) +![The People page: person cards, with one household (an owner, a grown-up and a kid member) grouped into a block across the page](/img/screenshots/admin/people.png) **People > People** shows everyone with an account as a card, with a count of accounts and how many were active this month. Each card shows: @@ -138,6 +138,8 @@ reach). Only Adam changes that, from the player. The list under it becomes **Access given here**: anything you give them directly, on top of the household. +![A kid household member's Access tab: Managed by maya and Kid under the name, Collections maya shares with them with one collection, and Access given here with nothing given directly](/img/screenshots/admin/person-member.png) + - **Give access** adds a whole library or one of your shares they don't have yet. - **Remove** takes one away. diff --git a/docs-users/listening/account.md b/docs-users/listening/account.md index 31e3e97..b804b19 100644 --- a/docs-users/listening/account.md +++ b/docs-users/listening/account.md @@ -26,6 +26,19 @@ Why it's worth doing, especially if you were invited by pairing and never set on Demo accounts can't have a password, so the card isn't shown for them. +If someone added you to their [household](household.md), the card says so: *"Adam set up this account, so it has no password yet. Set one to sign in on a new device yourself."* + +## A kid's account + +A kid's account from someone's [household](household.md) has a simpler page, because the grown-up who manages it looks after its devices: + +- The **Password** card says *"A kid's account has no password. Adam signs it in on a new device."*, with no button: a kid's account can't have one. +- **Pair another device** doesn't make a code: *"A kid's account can't pair a device. The person who manages it sets up its devices from Household."* +- There's no **Personal API keys** section. +- [Listening in the house](in-the-house.md) is off, and its switch on the Household page says *"A kid's account can't share what it listens to."* + +When the person who manages it switches the account between kid and grown-up, the page follows the next time it's opened. + ## Pair another device Sign another phone, tablet or browser in to the same account without a new invite: @@ -56,7 +69,7 @@ The list appears on servers new enough to show it. ## Personal API keys -**Personal API keys** let dashboards, scripts and other tools reach your server on your behalf, such as a Home Assistant or Heimdall tile. See [API keys for integrations](api-keys.md) for creating, using and revoking them. On a server too old to offer them, the section says so; demo accounts don't have it. +**Personal API keys** let dashboards, scripts and other tools reach your server on your behalf, such as a Home Assistant or Heimdall tile. See [API keys for integrations](api-keys.md) for creating, using and revoking them. On a server too old to offer them, the section says so; demo accounts and kids' accounts don't have it. ## Signing out @@ -64,6 +77,8 @@ The list appears on servers new enough to show it. If you're about to sign out **without a password set**, the app stops you with a warning - *"Without one you'll need a new invite from your admin to sign back in on this server"* - and offers to **Set a password** right there. Take the offer; it's the whole reason the warning exists. If books from this server are downloaded on the device, the warning also says how many you'd lose. +For a member of someone's household, the warning names who can sign you in again instead: *"Without a password, Adam can sign you in again from their Household page."*, or for a kid's account, *"A kid's account has no password. Adam can sign it in again from their Household page."* + Signing out **takes this server out of your app** and deletes your downloads from it on this device, along with any of your progress that hasn't synced yet. Anyone else on this device signed in to the same server keeps theirs. (Books, progress, and bookmarks stored on the server are safe - sign back in and they're all there.) :::tip diff --git a/docs-users/listening/connecting.md b/docs-users/listening/connecting.md index fcf2f67..fab9ef7 100644 --- a/docs-users/listening/connecting.md +++ b/docs-users/listening/connecting.md @@ -62,7 +62,9 @@ Instead of an address you can use: - **Scan a QR code** (in the iOS and Android apps) - the pairing QR from the server's connect page or from another of your devices. - **I have a pairing link** (in the web player) - paste a pairing link your admin or another of your devices sent you (it looks like `https://books.example.com/web/connect?token=…`) and tap **Connect**. -Either signs you in straight away, with nothing to type. +Either signs you in straight away, with nothing to type. A pairing link works once and for a short time, so one that has expired or was already used says *"This pairing link has expired or was already used. Ask for a new one, or enter your invite code on the sign-in screen."* After a lot of tries in a row, the server asks you to wait: *"Too many tries. Wait a few minutes and try again."* + +On a device in [kids mode](kids-mode.md#links-in-kids-mode), a link or QR code isn't opened at all: the app asks for a grown-up to open it. ### Invite codes diff --git a/docs-users/listening/household.md b/docs-users/listening/household.md index a94ccb7..169f96c 100644 --- a/docs-users/listening/household.md +++ b/docs-users/listening/household.md @@ -11,26 +11,31 @@ The **Household** page is where you look after the people you listen with: who u - **On a tablet or computer**: **Household** in your [profile menu](browsing.md#getting-around) (top right). - From anywhere: **Manage household** on [Who's listening?](profiles.md#whos-listening), **Household** at the end of the App group in [Settings](settings.md#household), and **Sharing settings** beside Listening in the house on your Home page. In the web player's quick search, **Go to** lists it too. +![The Household page on a phone: the Me tab on Household, with On this device and the first person's card](/img/screenshots/web-player/phone-household.png) + The page has three parts: -- **On this device**: everyone who listens on this phone, tablet or computer, with their name, PIN and kids mode (see [Sharing a device with your household](profiles.md#on-the-household-page)). -- **Your household**: the people you've added to your household on your server, one card each, and **Add someone to your household**. With more than one server, each one that offers it has its own section, *Your household on Hearthside*. +- **On this device**: everyone who listens on this phone, tablet or computer, a card each with their name, PIN and kids mode, then **Who's listening?**, **Sign in someone who already has an account** and **Ask who's listening when the app opens** (see [Sharing a device with your household](profiles.md#on-the-household-page)). +- **Your household**: *"People you've added. You choose what each of them can listen to."* One card for each person you've added to your household on your server, then **Add someone to your household**. With more than one server, each one that offers it has its own section, *Your household on Hearthside*. - **Listening in the house**: the **Share what I'm listening to with the house** switch, one per server (see [Listening in the house](in-the-house.md#turning-it-on)). +![The Household page in light appearance, at Your household: Leo's card (a kid, on this device) and Ruth's (a grown-up), Add someone to your household with 2 of 6 added, then Listening in the house](/img/screenshots/web-player/household.png) + ## Household members A **household member** is someone you add yourself, from the player: a kid, or another grown-up you live with. You don't need the server's admin, an invite or an email address. Each member gets **their own account** on your server, so they have their own place in every book, their own Up next, bookmarks and listening stats, and nothing they do moves yours. You manage the people you add: you choose what they can listen to, rename them, sign their devices out and delete their account. They can't add people of their own, and a member is never an admin. -The **Your household** section shows how many you've added, *"2 of 6 added"*, against the most the server allows. If you can't add anyone, it says why: +Beside **Add someone to your household**, the section shows how many you've added, *"2 of 6 added"*, against the most the server allows. Before you've added anyone it says *"Add a kid or a grown-up. They get their own account and their own place in every book, with no email or invite."* If you can't add anyone, it says why instead of offering the button: - *"Your server's admin has turned off adding people."*: the admin turned household members off for the whole server. - *"Your server's admin hasn't let this account add people."*: the admin turned it off for your account. -- *"Only the person who manages your account can add people."*: you are someone's household member yourself. - *"Demo accounts can't add people."* - *"You've added the most people this server allows (6)."* +If you are someone's household member yourself, the section says who manages your account (*"Adam manages your account."*) and *"Only Adam can add people or change what you can listen to."* + Even when adding is turned off, you can still rename, sign out and delete the people you added before. On a server running an older AudioSilo, the section isn't there at all. ## Adding a kid @@ -38,12 +43,16 @@ Even when adding is turned off, you can still rename, sign out and delete the pe 1. On the Household page, tap **Add someone to your household**. 2. Type their **Name** (up to 40 characters). Their username on the server is made from it (*evie*, or *evie-adam* if that's taken), and you can rename them later. 3. Leave **Kids account** on: *"No password. Signed in on this device straight away, in kids mode."* -4. Under **What can they listen to?**, a kid starts on **Only collections I share** (see [What they can listen to](#what-they-can-listen-to)). Tick the collections to share with them, or tap **Create Evie's books** to make a new, empty one for them. +4. Under **What can they listen to?**, a kid starts on **Only collections I share** (see [What they can listen to](#what-they-can-listen-to)). Tick the collections of yours to share with them, or tap **Create Evie's books** for a new one: *Evie's books* joins the list, ticked, as a *"New collection, created when you save"*. Untick it to take it back. If you already have a collection called *Evie's books*, the button says **Share Evie's books** and ticks that one instead. 5. Tap **Add and sign in here**. -Evie is now a person on this device, in [kids mode](kids-mode.md), with the default bedtime. You stay the person listening, and whatever was playing carries on: choose Evie on Who's listening? when it's their turn. +![Adding a kid: the name typed, Kids account on, Only collections I share with an existing collection and the new Mia's books ticked, and Add and sign in here](/img/screenshots/web-player/household-add-kid.png) -If your own profile on this device has no [PIN](profiles.md#a-pin-for-a-person), the app offers to set one (*"Set a PIN for Adam, so Evie can't switch to you without it."*). Take it: without a PIN, a child who leaves kids mode can choose you on Who's listening?. +The new collection is only made when you tap **Add and sign in here**, so **Cancel** leaves nothing behind. If the server refuses the add, it's deleted again and nothing changes. + +The dialog then says *"Evie is added"* and *"Evie is signed in on this device, in kids mode. Choose them in Who's listening? when it's their turn."* Evie is now a person on this device, in [kids mode](kids-mode.md), with the default bedtime. You stay the person listening, and whatever was playing carries on. + +If your own profile on this device has no [PIN](profiles.md#a-pin-for-a-person), the dialog offers to set one (*"Set a PIN for Adam, so Evie can't switch to you without it."*), with **Set a PIN** and **Not now**. Take it: without a PIN, a child who leaves kids mode can choose you on Who's listening?. A kids account has **no password**. It can't set one, create [API keys](api-keys.md), pair another device, share collections, or show up in (or see) [Listening in the house](in-the-house.md). It signs in only on devices you set up for it: this one, or its own device with [Set up their own device](#their-devices). @@ -55,6 +64,8 @@ A kids account has **no password**. It can't set one, create [API keys](api-keys The app shows **Show Ellis this code**: a QR code to scan with the AudioSilo app on their own phone, or a link to open. It works once, for 10 minutes. If they'll listen on your device too, tap **They'll also use this phone** (or **They'll also use this device**) to sign them in here as well, as a person of their own. +![A grown-up added: Show Ellis this code, the QR code with its link, They'll also use this device and Done](/img/screenshots/web-player/household-add-grownup.png) + A grown-up member is an ordinary account in every other way: they can set a password, pair their other devices, create API keys, share collections and share their listening with the house. The defaults follow the **Kids account** switch (a kid on Only collections I share, a grown-up on Everything I can listen to) until you pick one yourself. Either choice works for either kind of person. @@ -66,35 +77,47 @@ The defaults follow the **Kids account** switch (a kid on Only collections I sha On top of either, a member keeps anything the server's admin gave their account directly. -To change it, open their card and tap **Change** beside **What they can listen to**. When you share a collection with a member who is on Only collections I share, the share sheet says so: *"Evie can listen to everything in this collection."* +To change it, tap **Change** beside **What they can listen to** on their card: the same choice as when you added them, with **Create Evie's books** (or **Share Evie's books**) too, made when you tap **Save**. Moving someone from Only collections I share to **Everything I can listen to** stops sharing your collections with them, and the dialog says so first (*"Switching to everything stops sharing your collections with Evie."*). For a kid, kids mode then shows one library again instead of those collections. + +When you share a collection with a member who is on Only collections I share, the share sheet says so: *"Evie can listen to everything in this collection."* For a kid, the shared collections are what [kids mode](kids-mode.md#what-the-child-sees) shows. ## A member's card -Each person you've added has a card on the Household page: their name, *Kid* or *Grown-up* with their username, and **On this device** when they're signed in here. On it: +Each person you've added has a card on the Household page: their name, *Kid* or *Grown-up* with their username (*"Kid · evie"*), and **On this device** when they're signed in here. On it: -- **What they can listen to**, with each shared collection (tap one to open it) and **Change**. -- **Kids account**: switch it on to make a grown-up a kid. The app asks first (*"Make Ellis a kids account?"*): their password and API keys are removed, and the devices they're signed in on stay signed in. The server also cancels any unused pairing code or invite of theirs and their recovery code, stops them sharing their listening with the house, and ends any collection sharing between them and anyone but you. Switching it off makes a kid a grown-up, who can then set a password, and turn on sharing with the house again if they want it. +- **What they can listen to**: *Everything you can listen to*, or *Only the collections you share:* with each one and how many books it holds (tap one to open it), and **Change**. +- **Kids account** (*"No password or API keys. You sign them in."*): switch it on to make a grown-up a kid. The app asks first (*"Make Ellis a kids account?"*): their password and API keys are removed, and the devices they're signed in on stay signed in. The server also cancels any unused pairing code or invite of theirs and their recovery code, stops them sharing their listening with the house, and ends any collection sharing between them and anyone but you. Switching it off makes a kid a grown-up, who can then set a password, and turn on sharing with the house again if they want it. When they're signed in on this device too, their account here follows the switch straight away. - **Their devices** (below). -- **Name**, with **Rename**: the name everyone on the server sees. Their username stays the same. +- **Name**, with **Rename**: the name everyone on the server sees, and, when they're signed in here, their name on this device too. Their username stays the same. - **Delete their account** (below), and **Remove from this device** when they're signed in here. +![Leo's member card: Kid and On this device, What they can listen to with a shared collection and Change, the Kids account switch, Their devices with this device marked, Set up their own device, Name with Rename, and Delete their account beside Remove from this device](/img/screenshots/web-player/household-member.png) + +If a change can't be made, a message says nothing changed and why, in the app's own words: for example *"That person isn't in your household on Hearthside any more."* (they were deleted or moved from another device, and the list catches up), *"This account is turned off on Hearthside. Only the server's admin can turn it back on."*, or *"Too many tries. Wait a few minutes and try again."* + ## Their devices -The card lists every device the member is signed in on, each with **Sign out**. Signing one out means they can't listen there until you set it up again. +The card lists every device the member is signed in on, each with **Sign out** (*"Evie isn't signed in anywhere yet."* when there are none). When they're signed in here, this device's row is marked **This device**. Signing a device out means they can't listen there until you set it up again; signing them out of this one shows them as signed out on this device at once, on their card under **On this device**. - **Set up their own device** shows a code for their own phone or tablet: they scan it with the AudioSilo app, and it signs straight in. On a kid's own device the app starts in [kids mode](kids-mode.md#on-a-kids-own-device), even though no grown-up is signed in there. -- **Sign in on this device** signs them in here too, when they aren't already. +- **Sign in on this device** signs them in here too, as a person of their own, when they aren't already. You stay the person listening; a kid starts in kids mode. + +If the server's admin has turned the member's account off, neither works: the app says *"Evie's account is turned off on this server. Only its admin can turn it back on."* The devices they're already signed in on stop working until the admin turns it back on. You can still rename them, sign them out and delete them. + +## When a member is signed out on this device + +If someone's sign-in on this device ends (you signed this device out on their card, say), their card under **On this device** shows **Signed out** (*"Evie's sign-in to Hearthside has ended."*) with **Sign in again**. While you're the person listening, that signs a member of your household straight back in, with no password to type, and they keep their place on this device, their PIN and kids mode. For anyone else, and after the server was reset, it opens the usual sign-in for that server, which keeps them the same person on this device. -If the server's admin has turned the member's account off, neither works: the app says the account is turned off on the server and only its admin can turn it back on. The devices they're already signed in on stop working until the admin turns it back on. You can still rename them, sign them out and delete them. +A kid can't do this from kids mode: their screen says *"Ask a grown-up to sign Evie in again."* Hold **Grown-ups**, choose yourself, and tap **Sign in again** on their card (see [Kids mode](kids-mode.md#if-a-kid-is-signed-out)). ## Deleting a member, or removing them from this device These are different: -- **Remove from this device** signs them out of your server on this device and deletes their downloads here. Their account, and their place in every book, stay on the server, and **Sign in on this device** on their card brings them back. It's the same as removing anyone else from the device (see [Sharing a device with your household](profiles.md#on-the-household-page)). -- **Delete their account** deletes the account itself, on the server, with their listening history, bookmarks and notes. It can't be undone, so the app asks twice: once to **Continue**, then you type their name and tap **Delete account**. They stay on this device as a person until you remove them from it too. +- **Remove from this device** signs them out of your server on this device and deletes their downloads here. Their account, and their place in every book, stay on the server, and the confirmation says so: *"Evie's account on Hearthside stays. Adam can sign Evie in here again from the Household page, or set up a device just for Evie."* **Sign in on this device** on their card brings them back. It's the same as removing anyone else from the device (see [Sharing a device with your household](profiles.md#on-the-household-page)). +- **Delete their account** deletes the account itself, on the server, with their listening history, bookmarks and notes. It can't be undone, so the app asks twice: first *"Delete Evie's account?"* with **Continue**, then *"Type Evie to delete their account"*: type their name and tap **Delete account**. When they're signed in here, it reminds you that they stay on this device as a person until you remove them from it too. ## If a phone is lost diff --git a/docs-users/listening/in-the-car.md b/docs-users/listening/in-the-car.md index eb4f3c1..9fda70b 100644 --- a/docs-users/listening/in-the-car.md +++ b/docs-users/listening/in-the-car.md @@ -41,7 +41,7 @@ When several people share the phone ([profiles](profiles.md)), the car shows onl ### A kids profile in the car -When the person listening is in [kids mode](kids-mode.md), the car shows only their stories: **Your stories**, the same stories as the kids screen, and **Continue listening** for the ones they're part-way through. There's no Up next or Downloads tab, and nothing else their account could reach. Switching to a grown-up, or turning kids mode off, changes the lists straight away. +When the person listening is in [kids mode](kids-mode.md), the car shows only their stories: **Your stories**, the same stories as the kids screen (the first 50, as in the Library tab), and **Continue listening** for the ones they're part-way through. Bedtime's sleep timer doesn't start in the car. There's no Up next or Downloads tab, and nothing else their account could reach. Switching to a grown-up, or turning kids mode off, changes the lists straight away. ## Playing a book diff --git a/docs-users/listening/kids-mode.md b/docs-users/listening/kids-mode.md index e8a8de9..a2673f5 100644 --- a/docs-users/listening/kids-mode.md +++ b/docs-users/listening/kids-mode.md @@ -13,7 +13,9 @@ Kids mode belongs to a [profile](profiles.md): a person on this device. On a server that offers it, open the [Household page](household.md), tap **Add someone to your household**, type the child's name and leave **Kids account** on (see [Adding a kid](household.md#adding-a-kid)). The child gets an account of their own on your server, made by you with no admin, and is signed in on this device straight away, already in kids mode with the default bedtime. You stay the person listening. -They hear what you share with them: **Only collections I share** (the default for a kid) gives them just the books in the collections you choose, and kids mode shows those collections as their stories. Add a book to one of those collections and it's on their shelf at once. +They hear what you share with them: **Only collections I share** (the default for a kid) gives them just the books in the collections you choose, and kids mode shows those collections as their stories. Add a book to one of those collections and it's on their shelf at once. If you change them to **Everything I can listen to**, your collections stop being shared with them and kids mode shows one library instead (the **Library** below). + +A kid's account is in kids mode wherever it signs in for the first time: on your device when you add them, and on their own phone or tablet when it scans the code from **Set up their own device**. It goes straight to the kids screen, with no *"Your library is ready."* page first. ### Kids mode for any profile @@ -26,7 +28,7 @@ Kids mode also works for a child who signs in with an account the server's admin Under the child's name on the Household page, with **Kids mode** on, pick: -- **Library**: the one library kids mode shows, when the child's account can see more than one and you share no collections with them. Left alone, it's the first one the account lists. +- **Library**: the one library kids mode shows, when the child's account can see more than one. Left alone, it's the first one the account lists. For a kid in your household it's only there while you share no collections with them (*"Shown when Adam shares no collections"*): while you do, those collections are the shelf. - **Bedtime**: when bedtime starts, **7:30 PM** unless you change it (times are written the way the app's language writes them: 7:30 PM in English, 19:30 in the other languages). If you turn kids mode on while the child is the one listening, the app goes straight into kids mode. To pick the library and bedtime then, hold **Grown-ups**, choose yourself and open the Household page. @@ -41,7 +43,7 @@ It's a good idea to give the grown-ups' profiles a [PIN](profiles.md#a-pin-for-a - **"Hi Leo"** at the top, with the **Grown-ups** button. - **The bedtime card**: *"Bedtime is at 7:30 PM"*, and what happens then. At bedtime it says *"It's bedtime"*, and while the sleep timer runs, how many minutes it has left. -- **The story playing now**, in a big player: the cover, the title, the chapter and the minutes left in it, a line showing how far through the chapter they are, and four big buttons: **previous chapter**, **go back 30 seconds**, **play/pause** and **next chapter**. No speed, no sleep menu, no bookmarks. +- **The story playing now**, in a big player: the cover, the title, the chapter and the minutes left in it, a line showing how far through the chapter they are, and four big buttons: **previous chapter**, **go back 30 seconds**, **play/pause** and **next chapter**. No speed, no sleep menu, no bookmarks. Stories start at normal speed (1×), whatever the device's **Default speed** is, so a grown-up's faster speed never carries over to a child (a story that already has a speed saved for the child keeps it). - **Your stories**: big cover tiles, the stories they're part-way through first, then every other book, finished ones too (children listen again). Tap one and it plays right there. For a kid in your household, the stories are the books in the collections you share with them, grouped under each collection's name when there are several; while those collections have no stories yet, it says *"Ask a grown-up to add stories."* Anyone else, and a kid you haven't shared a collection with yet, sees the one library chosen for them (if their account can see one). ![Kids mode on a phone: the bedtime card, the four big player buttons and the story tiles in two columns](/img/screenshots/web-player/phone-kids.png) @@ -50,7 +52,13 @@ In the car, a kids profile's [CarPlay or Android Auto](in-the-car.md#a-kids-prof If the server can't be reached, kids mode shows the stories downloaded to this device for the child's account, so a downloaded story still plays in the car or on a plane. They come from the child's library when the app knows which one that is: the **Library** you picked, or the one it was showing before the server went away. If the app is opened while the server can't be reached and no Library was picked, every story downloaded for the child's account shows. With no downloads, it says the stories can't load and offers **Try again**. -If the child's account needs signing in again (for example after the server was reset), a banner on the kids screen says so. Signing in again there keeps the child's profile and kids mode. +### If a kid is signed out + +If the child's sign-in ends (a grown-up signed that device out, or the server was reset), the kids screen says *"Ask a grown-up to sign Leo in again."* It says so above the stories still on the device, or under *"Signed out of Hearthside"* when there are none. There's no button for the child to press: signing in is a grown-up's job. Hold **Grown-ups**, choose yourself, open the [Household page](household.md#when-a-member-is-signed-out-on-this-device) and tap **Sign in again** on the child's card under **On this device**. For a kid in your household that signs them straight back in, with no password, keeping the child's profile and kids mode. + +### Links in kids mode + +Kids mode never opens a sign-in or pairing link, or a QR code scanned for this device: the app goes back to the kids screen and says *"Ask a grown-up to open this link"* (*"A grown-up holds Grown-ups first, then opens the link again."*). Nothing is signed in and the link isn't used up, so a grown-up can open it once they've left kids mode. ## Bedtime @@ -63,7 +71,7 @@ From the bedtime you set until **6:00 AM** the next morning, starting or resumin **Press and hold Grown-ups for 2 seconds.** While it's held the button says *Keep holding* and a fill grows across it; let go early and nothing happens. (With reduced motion on there's no fill, and it still takes the full 2 seconds. A screen reader announces it as a hold to leave kids mode.) -Holding it opens [Who's listening?](profiles.md#whos-listening): choose a grown-up (with their PIN, if they have one) to leave kids mode, or the child again to go back. While a child is listening and a grown-up is on the device, **Add someone** is turned off there ("Choose a grown-up before adding someone."), and so is signing in to a new server or the demo, so a child can't get out of kids mode by adding a new person. Choose a grown-up first. On Android, the back button never takes a child out of kids mode (at most it closes the app). +Holding it opens [Who's listening?](profiles.md#whos-listening): choose a grown-up (with their PIN, if they have one) to leave kids mode, or the child again to go back. While a child is listening and a grown-up is on the device, **Add someone** is turned off there (*"Choose a grown-up before adding someone."*), so a child can't get out of kids mode by adding a new person. Choose a grown-up first. On Android, the back button never takes a child out of kids mode (at most it closes the app). To stop using kids mode for good, turn **Kids mode** off for that person on the [Household page](household.md). That only changes what this device shows: what the account may listen to is the server's to decide, so turning kids mode off never lets a child hear more. @@ -71,4 +79,4 @@ To stop using kids mode for good, turn **Kids mode** off for that person on the A kid in your household can have a phone or tablet of their own: on their card on the Household page, tap **Set up their own device** and scan the code with the AudioSilo app on their device (see [Their devices](household.md#their-devices)). The app there starts in kids mode by itself, even though no grown-up is signed in on it. -On that device, holding **Grown-ups** opens Who's listening? with only the child on it, and **Add someone** stays available there: a grown-up can sign in with their own account to change the bedtime or turn kids mode off, and then remove themselves from the device again. A kids account can't set a password, make API keys, pair another device or share its listening with the house, whichever device it's on. +On that device, holding **Grown-ups** opens Who's listening? with only the child on it, and **Add someone** stays available there: a grown-up can sign in with their own account to change the bedtime or turn kids mode off, and then remove themselves from the device again. That is the only way from kids mode into signing in: a link opened there is turned away as on any device (see [Links in kids mode](#links-in-kids-mode)). A kids account can't set a password, make API keys, pair another device or share its listening with the house, whichever device it's on. diff --git a/docs-users/listening/profiles.md b/docs-users/listening/profiles.md index d306272..a0ddc63 100644 --- a/docs-users/listening/profiles.md +++ b/docs-users/listening/profiles.md @@ -11,7 +11,7 @@ If you were already using AudioSilo when profiles arrived, nothing changes until **Who's listening?** shows everyone on this device as a large initial in a coloured circle, with a line under each name. The person listening now has a pink ring. Tap someone to switch to them, or **Close** to carry on as you are. -![Who's listening? on a computer: Ellen with the pink ring, Jamie with a key for the PIN, Leo in kids mode with their bedtime, and Add someone](/img/screenshots/web-player/profiles.png) +![Who's listening? on a computer: Ellen with the pink ring, Jamie with a key for the PIN, Leo in kids mode with the bedtime, Add someone, and Manage household at the foot](/img/screenshots/web-player/profiles.png) Open it from: @@ -53,7 +53,7 @@ Each person can have a **PIN**: four digits, asked whenever someone switches **t ## On the Household page -![A person's card on this device: Leo's card with Switch, Name, PIN, Kids mode, Library and Bedtime, then Ask who's listening when the app opens and the Listening in the house switch](/img/screenshots/web-player/settings-household.png) +![On this device on the Household page: Leo's card with Switch, Name, PIN, Kids mode and Bedtime, then Who's listening?, Sign in someone who already has an account, and Ask who's listening when the app opens](/img/screenshots/web-player/household-device.png) **On this device**, at the top of the [Household page](household.md), lists everyone on this device. For each person: @@ -61,7 +61,8 @@ Each person can have a **PIN**: four digits, asked whenever someone switches **t - **Name**: rename them. The name shows on this device only; their account keeps its username; - **PIN**: set, change or remove it (above); - **Kids mode**, with its library and bedtime (see [Kids mode](kids-mode.md)); -- **Remove from this device**: signs them out of their servers on this device and deletes their downloads here. Their place in every book stays on the server. If they have no password on a server, the app warns you first: they'll need a new invite to sign in there again (a member of your household needs no invite: **Sign in on this device** on their card signs them in again). +- **Signed out**, with **Sign in again**, when their sign-in to a server has ended (see [When a member is signed out on this device](household.md#when-a-member-is-signed-out-on-this-device)); +- **Remove from this device**: signs them out of their servers on this device and deletes their downloads here. Their place in every book stays on the server. If they have no password on a server, the app warns you first: they'll need a new invite to sign in there again. For a member of someone's household it says instead that their account stays, and who can sign them in here again from the Household page. Below the people: **Who's listening?**, **Sign in someone who already has an account**, and the **Ask who's listening when the app opens** switch. Further down the page come the people you've added to [your household](household.md#household-members) and [Listening in the house](in-the-house.md#turning-it-on), one switch per server of the person listening. diff --git a/docs-users/listening/settings.md b/docs-users/listening/settings.md index d57cf67..6acb553 100644 --- a/docs-users/listening/settings.md +++ b/docs-users/listening/settings.md @@ -25,7 +25,7 @@ Everything here except your servers is **kept per device**, so your phone and yo |---|---|---| | **Skip back** | The jump of the player's back button | 5-120 s (default 15 s) | | **Skip forward** | The jump of the forward button | 5-120 s (default 30 s) | -| **Default speed** | Starting speed for books you haven't played yet (each book then remembers its own) | 0.5×-2× | +| **Default speed** | Starting speed for books you haven't played yet (each book then remembers its own). [Kids mode](kids-mode.md) always starts at 1× | 0.5×-2× | | **Auto-rewind on resume** | How far playback backs up after a pause, so you regain the thread | Off-30 s (default 5 s) | | **Chapter length (unchaptered)** | Size of the virtual chapters created for long books with no chapter markers | 5-60 min (default 30 min) | | **Smart speed** | Shortens the silences between words ([where it works](full-player.md#smart-speed-and-voice-boost)) | On / Off (default **Off**) | diff --git a/screenshots/README.md b/screenshots/README.md index 4a97911..2dec60a 100644 --- a/screenshots/README.md +++ b/screenshots/README.md @@ -97,7 +97,10 @@ build in `X-AudioSilo-Client`), saves each one's progress twice 15 s apart before the first shot (so the overview and the people cards show them live), and keeps saving every 15 s in the background. Theo's device fetches a `?transcode=1` stream first (so his session counts as transcoded) and has an -unused personal API key; nora stops 90 s before the Activity shots, so Live now +unused personal API key; maya adds two household members through the player's API +(`POST /me/household/members` as maya: ivy, a kid on "Ivy's books", a collection of +hers, and dan, a grown-up on everything), which `admin/people.png` shows as maya's +household and `admin/person-member.png` as ivy's Access tab; nora stops 90 s before the Activity shots, so Live now shows her as paused. The Activity screens are captured last, once `LISTEN_MINUTES` (default 8) have passed since the listeners started, so the charts and totals have twenty-odd minutes of listening to show rather than a @@ -164,32 +167,46 @@ Every capture context reduces motion, so the Year in listening story stays on it card. The desktop You shot picks Stats in the sub-nav first: the Journal shot (opened from the profile menu) leaves the Me tab on its Journal section. -The household shots (`web-player/profiles.png`, `settings-household.png`, -`home-house.png`, `book-in-the-house.png`, `kids.png`, `phone-kids.png`, -`player-cast.png`) need people rather than the demo user: a demo account can't share -what it listens to, and the people on a device, their PINs and kids mode live in that -browser's `localStorage`. So `capture-web.mjs` provisions four accounts through the -admin API (`ADMIN_PASSWORD`): **ellen** (the viewer, with a place in A Christmas -Carol), **jamie** and **rosa** (who share; their listening comes from progress saves -as the apps make them, two 15 s apart, and jamie keeps saving every 15 s, so the house -shows jamie listening now and rosa paused once the server reads her last save as a -minute old; she is saved again if more than 5 minutes passed before Home is shot) and -**leo** (a child, whose account only sees a "Stories" share of the Lewis Carroll and -Charles Dickens folders). Then it drives the UI as a family would, in a fresh browser: -Ellen signs in by password, Settings > Household and sharing renames her, "Add -someone" signs Jamie and Leo in as people of their own, Ellen switches back, gives -Jamie a PIN, turns kids mode on for Leo (library Books) and turns sharing on. The -Settings shot is taken at 1440x1000 with Leo's card at the top. Leo's shots choose him -on "Who's listening?" (a reload asks, with two or more people) and load a story, -paused; the phone shot reuses that browser's storage. Every household context runs in a -fixed-offset time zone where it is 8 pm, after Leo's 7:30 pm bedtime, so the kids -bedtime card reads the same on every run. If setting the household up fails part-way, -its shots are skipped and keep their previous images. The accounts and the share are -deleted again at the end, even when a step failed part-way, and their sign-ins are -taken out of the server's event feed (sqlite3), so the admin console's shots never -show them (the Audit log keeps the admin's create and delete entries); any accounts -left over from a run that was killed are deleted before provisioning. A run that only wants -`player-cast.png` (`SHOTS_ONLY`) provisions Ellen alone. +The household shots (`web-player/profiles.png`, `household.png`, `household-device.png`, +`household-member.png`, `household-add-kid.png`, `household-add-grownup.png`, +`phone-household.png`, `home-house.png`, `book-in-the-house.png`, `kids.png`, +`phone-kids.png`, `player-cast.png`) need people rather than the demo user: a demo +account can't share what it listens to or add household members, and the people on a +device, their PINs and kids mode live in that browser's `localStorage`. So +`capture-web.mjs` provisions three accounts through the admin API (`ADMIN_PASSWORD`): +**ellen** (the viewer and an ordinary owner, with a place in A Christmas Carol), +**jamie** and **rosa** (who share; their listening comes from progress saves as the +apps make them, two 15 s apart, and jamie keeps saving every 15 s, so the house shows +jamie listening now and rosa paused once the server reads her last save as a minute +old; she is saved again if more than 5 minutes passed before Home is shot). Then, as +Ellen, through the player's own API, it makes a "Leo's stories" collection (Alice, +Looking-Glass, A Christmas Carol) and two household members with +`POST /me/household/members`: **Leo**, a kid on "Only collections I share" with that +collection, and **Ruth**, a grown-up on "Everything I can listen to". Then it drives the +UI as a family would, in a fresh browser: Ellen signs in by password, the Household page +renames her, "Sign in someone who already has an account" signs Jamie in as a person of +their own, Ellen switches back, gives Jamie a PIN, signs Leo in with "Sign in on this +device" on his member card (he joins the device in kids mode, as a kid's account does) +and turns sharing on. The On this device shot is taken at 1440x1000 with Leo's card at +the top, the member card shot clips Leo's card (marked On this device, with this +browser as This device), and the Household page shot opens the same storage in a light +context (the app's own theme pick set to light) at 1440x1650, scrolled to Your +household. The add dialog shots type Mia as a kid and pick "Create Mia's books" (then +Cancel: nothing is saved), and add **Ellis** as a grown-up for the code step (Ellis is +a real member until the end of the run). Leo's shots choose him on "Who's listening?" +(a reload asks, with two or more people) and load a story, paused; the phone shots +reuse that browser's storage. Every household context runs in a fixed-offset time zone +where it is 8 pm, after Leo's 7:30 pm bedtime, so the kids bedtime card reads the same +on every run. If setting the household up fails part-way, its shots are skipped and keep +their previous images. The accounts are deleted again at the end (Ellen's members go +with her), even when a step failed part-way, and their sign-ins are taken out of the +server's event feed and Ellen's member writes out of the audit log (sqlite3), so the +admin console's shots never show them (the Audit log keeps the admin's create and delete +entries); any accounts left over from a run that was killed are deleted before +provisioning. The member routes share the server's self-service rate limit (10 per +address in 15 minutes): the web household spends five of them and the admin run two, +so don't run the household twice against one server within 15 minutes. A run that only +wants `player-cast.png` (`SHOTS_ONLY`) provisions Ellen alone. The Cast button only shows on an https page in a Chromium browser, and for a server reached at a loopback address it is only dimmed and explains why it can't cast (its diff --git a/screenshots/capture-admin.mjs b/screenshots/capture-admin.mjs index 412fc26..4777db2 100644 --- a/screenshots/capture-admin.mjs +++ b/screenshots/capture-admin.mjs @@ -262,6 +262,16 @@ const LISTENERS = [ ]; let mayaId = null; let heartbeats = null; +// Maya's household (Phase 8b-1): two members she adds from the player's own API +// (POST /me/household/members as maya), a kid on the collection she shares with them and +// a grown-up on everything she can listen to, so People shows a household block and a +// member's page has the household access to show. +const MEMBERS = [ + {name: 'Ivy', kind: 'kid', access: 'collections'}, + {name: 'Dan', kind: 'grownup', access: 'all'}, +]; +const MEMBER_COLLECTION = {name: "Ivy's books", books: 2}; +let memberId = null; // the kid's account const saveProgress = async (l) => { const now = Date.now(); @@ -311,6 +321,23 @@ await step('provision listening sessions', async () => { } mayaId = LISTENERS[0].id; + // Maya's household: a collection of hers for Ivy, then the two members. + { + const maya = LISTENERS[0]; + const made = await api(maya.token, 'POST', '/me/collections', {name: MEMBER_COLLECTION.name}); + const items = books.slice(0, MEMBER_COLLECTION.books).map((b) => ({library_id: library.id, path: b.rel_path})); + await api(maya.token, 'PUT', `/me/collections/${made.collection.id}/items`, {items}); + for (const m of MEMBERS) { + const {member} = await api(maya.token, 'POST', '/me/household/members', { + name: m.name, + kind: m.kind, + access: m.access, + ...(m.access === 'collections' ? {collection_ids: [made.collection.id]} : {}), + }); + if (m.kind === 'kid') memberId = member.id; + } + } + // A transcoded stream from Theo's device: the mark lasts 10 minutes, so his // session on the book is recorded as transcoded. const theo = LISTENERS.find((l) => l.transcode); @@ -345,7 +372,7 @@ await step('provision listening sessions', async () => { saveProgress(l).catch((e) => console.log(` ! heartbeat for ${l.username}: ${e.message}`)); } }, HEARTBEAT_MS); - console.log(' ✓ provisioned (maya, theo and nora listening)'); + console.log(' ✓ provisioned (maya, theo and nora listening; maya\'s household)'); }); // ── Capture ──────────────────────────────────────────────────────────────── @@ -532,6 +559,12 @@ await step('a person (Access tab)', async () => { await shoot(page, 'admin/person.png'); }); +await step("a household member's page (Access tab)", async () => { + if (!memberId) throw new Error('no household member was provisioned'); + await open(page, `/people/user/${memberId}?tab=access`); + await shoot(page, 'admin/person-member.png'); +}); + await step('invite someone', async () => { await open(page, '/people'); await page.getByRole('button', {name: 'Invite someone', exact: true}).first().click({timeout: 8000}); diff --git a/screenshots/capture-web.mjs b/screenshots/capture-web.mjs index 5edce4e..4d43673 100644 --- a/screenshots/capture-web.mjs +++ b/screenshots/capture-web.mjs @@ -9,8 +9,9 @@ // records (a place, a finish and rating, bookmarks, notes, history, a // sleep-timer drift-off) through the API, // 2. capture desktop, tablet and phone profiles into static/img/screenshots/web-player/, -// 3. the household: four accounts and a share made through the admin API -// (ADMIN_PASSWORD), driven through the UI for the profiles, kids mode and house +// 3. the household: three accounts made through the admin API (ADMIN_PASSWORD), and +// as one of them a collection and two household members through the player's own +// API, driven through the UI for the profiles, Household page, kids mode and house // shots and, through a local https proxy, the Cast shot, then deleted again. import {chromium} from 'playwright'; import path from 'node:path'; @@ -965,36 +966,55 @@ async function signInWithPassword(page, username, password) { await firstVisible(page.getByRole('button', {name: 'Sign in', exact: true})).click({timeout: 8000}); } -// ── Household: profiles, kids mode, listening in the house, Cast ──────────── -// The Phase 8 shots need people, not the demo: a demo account can't share what it -// listens to, and profiles, PINs and kids mode are this device's (the browser's -// localStorage), set up by driving the UI as a family would. provisionHouse makes the -// accounts through the admin API: Ellen (the viewer, with a book on the go), Jamie and -// Rosa (who share, Jamie listening now and Rosa paused, from progress saves as the -// apps make them) and Leo (a child, whose account an admin limits to the KIDS_SHARE -// folders). captureHouse then signs Ellen in on a fresh browser, adds Jamie and Leo -// with "Add someone", gives Jamie a PIN and Leo kids mode, and turns sharing on for -// Ellen in Settings > Household. unprovisionHouse deletes the accounts and the share -// again, and takes their sign-ins out of the server's event feed, so the admin console's -// shots (captured afterwards) never show them. +// ── Household: profiles, members, kids mode, listening in the house, Cast ─── +// The Phase 8 and 8b-1 shots need people, not the demo: a demo account can't share what +// it listens to or add household members, and profiles, PINs and kids mode are this +// device's (the browser's localStorage), set up by driving the UI as a family would. +// provisionHouse makes Ellen, Jamie and Rosa through the admin API: Ellen (the viewer and +// an ordinary owner, with a book on the go), Jamie and Rosa (who share, Jamie listening +// now and Rosa paused, from progress saves as the apps make them). Then, as Ellen, through +// the player's own API: a "Leo's stories" collection and two household members, +// POST /me/household/members: Leo (a kid on "Only collections I share", given that +// collection) and Ruth (a grown-up on "Everything I can listen to"). captureHouse signs +// Ellen in on a fresh browser, adds Jamie with "Sign in someone who already has an +// account", gives Jamie a PIN, signs Leo in with "Sign in on this device" on his member +// card (so he joins the device in kids mode, as a kid's account does) and turns sharing +// on for Ellen, on the Household page. The add dialog's shots type a kid's name without +// saving, then add a grown-up (Ellis) for the code step. unprovisionHouse deletes the +// accounts (Ellen's members go with her), takes their sign-ins out of the server's event +// feed and Ellen's member writes out of the audit log, so the admin console's shots +// (captured afterwards) never show them. const HOUSE_PASSWORD = 'household-demo'; const HOUSE_VIEWER = {username: 'ellen', name: 'Ellen', q: 'Christmas Carol', at: 0.4}; -// The others on Ellen's device, in the order "Add someone" adds them. -const HOUSE_DEVICE = [ - {username: 'jamie', name: 'Jamie', pin: '2468'}, - {username: 'leo', name: 'Leo', kids: true}, -]; +// The others on Ellen's device who sign in with their own account, in order. +const HOUSE_DEVICE = [{username: 'jamie', name: 'Jamie', pin: '2468'}]; // The housemates who share; `live` keeps saving (Listening now), the other stops after // two saves, so the house shows it Paused once its last save is a minute old. const HOUSE_MATES = [ {username: 'jamie', q: 'Hound of the Baskervilles', at: 0.35, live: true}, {username: 'rosa', q: 'Call of the Wild', at: 0.62}, ]; -const KIDS_SHARE = {name: 'Stories', paths: ['Lewis Carroll', 'Charles Dickens']}; +// Ellen's collection for Leo (title substrings of seeded books) and her household members, +// in the order she adds them. `here`: signed in on Ellen's device from the member card. +const KIDS_COLLECTION = {name: "Leo's stories", books: ["Alice's Adventures", 'Looking-Glass', 'Christmas Carol']}; +const HOUSE_MEMBERS = [ + {name: 'Leo', username: 'leo', kind: 'kid', access: 'collections', here: true}, + {name: 'Ruth', username: 'ruth', kind: 'grownup', access: 'all'}, +]; +// The add dialog's shots: a kid's name typed and never saved, and a grown-up added for +// the code step (deleted with Ellen). +const ADD_KID_NAME = 'Mia'; +const ADD_GROWNUP = {name: 'Ellis', username: 'ellis'}; const KIDS_STORY = 'Alice'; -const KIDS_USERNAME = HOUSE_DEVICE.find((p) => p.kids).username; +const KIDS_MEMBER = HOUSE_MEMBERS.find((m) => m.kind === 'kid'); // Every account the household makes (and a previous, interrupted run may have left). -const HOUSE_USERNAMES = [...new Set([HOUSE_VIEWER, ...HOUSE_DEVICE, ...HOUSE_MATES].map((p) => p.username))]; +const HOUSE_USERNAMES = [ + ...new Set([HOUSE_VIEWER, ...HOUSE_DEVICE, ...HOUSE_MATES, ...HOUSE_MEMBERS, ADD_GROWNUP].map((p) => p.username)), +]; +// The accounts the admin API makes (the members are Ellen's). +const HOUSE_ACCOUNTS = [...new Set([HOUSE_VIEWER, ...HOUSE_DEVICE, ...HOUSE_MATES].map((p) => p.username))]; +// The share a run before Phase 8b-1 made for Leo's account (removed if one was left). +const OLD_KIDS_SHARE = 'Stories'; // The server reports a housemate as playing while their newest save is under a minute // old (catalog livePlaying), paused up to ten minutes (SessionGap), then only "recent". // It stamps a save with its own clock, so a save can't be backdated: the Home shot waits @@ -1002,8 +1022,13 @@ const HOUSE_USERNAMES = [...new Set([HOUSE_VIEWER, ...HOUSE_DEVICE, ...HOUSE_MAT // when their last save is older than this, well inside the ten minutes. const HOUSE_RESAVE_AFTER_MS = 5 * MIN; const HOUSE_SHOTS = { + device: 'web-player/household-device.png', + page: 'web-player/household.png', + member: 'web-player/household-member.png', + addKid: 'web-player/household-add-kid.png', + addGrownUp: 'web-player/household-add-grownup.png', + phonePage: 'web-player/phone-household.png', profiles: 'web-player/profiles.png', - household: 'web-player/settings-household.png', homeHouse: 'web-player/home-house.png', bookHouse: 'web-player/book-in-the-house.png', kids: 'web-player/kids.png', @@ -1021,17 +1046,21 @@ function eveningZone() { // Make the household's accounts into `house` (see newHouse), recording each as it is // made, so unprovisionHouse removes whatever a failure half-way left. Anything an -// interrupted earlier run left (the accounts, the share) is removed first. `viewerOnly` -// (a run that only wants the Cast shot) makes Ellen and her book on the go alone. -const newHouse = () => ({admin: null, users: [], shareId: null, beat: null, mates: [], save: null, ready: false}); +// interrupted earlier run left (the accounts, the old share) is removed first. +// `viewerOnly` (a run that only wants the Cast shot) makes Ellen and her book on the go +// alone. +const newHouse = () => ({admin: null, users: [], members: [], beat: null, mates: [], save: null, ready: false}); async function provisionHouse(house, {viewerOnly = false} = {}) { if (!ADMIN_PASSWORD) throw new Error('ADMIN_PASSWORD not set: no household to provision'); const {token: admin} = await api(null, 'POST', '/auth/login', {username: 'admin', password: ADMIN_PASSWORD}); house.admin = admin; const stale = ((await api(admin, 'GET', '/admin/users'))?.users ?? []).filter((u) => HOUSE_USERNAMES.includes(u.username)); - for (const u of stale) await api(admin, 'DELETE', `/admin/users/${u.id}`); - const staleShares = ((await api(admin, 'GET', '/admin/shares'))?.shares ?? []).filter((s) => s.name === KIDS_SHARE.name); + // Owners last: deleting one deletes their members too. + for (const u of [...stale].sort((a, b) => Number(!a.managed_by) - Number(!b.managed_by))) { + await api(admin, 'DELETE', `/admin/users/${u.id}`).catch(() => {}); + } + const staleShares = ((await api(admin, 'GET', '/admin/shares'))?.shares ?? []).filter((s) => s.name === OLD_KIDS_SHARE); for (const s of staleShares) await api(admin, 'DELETE', `/admin/shares/${s.id}`); if (stale.length || staleShares.length) { console.log(` ✓ removed a previous run's ${[...stale.map((u) => u.username), ...staleShares.map((s) => `"${s.name}"`)].join(', ')}`); @@ -1043,21 +1072,12 @@ async function provisionHouse(house, {viewerOnly = false} = {}) { if (!b) throw new Error(`no seeded book matching "${t}"`); return b; }; - const names = viewerOnly ? [HOUSE_VIEWER.username] : HOUSE_USERNAMES; + const names = viewerOnly ? [HOUSE_VIEWER.username] : HOUSE_ACCOUNTS; for (const username of names) { const made = await api(admin, 'POST', '/admin/users', {username, password: HOUSE_PASSWORD, role: 'user'}); const id = made?.user?.id ?? made?.id; house.users.push(id); - if (username !== KIDS_USERNAME) { - await api(admin, 'POST', '/admin/library-access', {user_id: id, library_id: library.id}); - } else { - const share = await api(admin, 'POST', '/admin/shares', {name: KIDS_SHARE.name}); - house.shareId = share?.share?.id ?? share?.id; - for (const p of KIDS_SHARE.paths) { - await api(admin, 'POST', `/admin/shares/${house.shareId}/paths`, {library_id: library.id, path: p}); - } - await api(admin, 'POST', '/admin/share-access', {user_id: id, share_id: house.shareId}); - } + await api(admin, 'POST', '/admin/library-access', {user_id: id, library_id: library.id}); } const progress = (token, book, body, client) => api(token, 'PUT', `/libraries/${library.id}/progress${pathQuery(book.rel_path)}`, { @@ -1069,12 +1089,30 @@ async function provisionHouse(house, {viewerOnly = false} = {}) { const login = async (username) => (await api(null, 'POST', '/auth/login', {username, password: HOUSE_PASSWORD, device_name: `${username}'s phone`})).token; - // Ellen's book on the go (one save: a place, no listening), so her Home has a Now card. + // Ellen's book on the go (one save: a place, no listening), so her Home has a Now card; + // then, as Ellen through the player's API, Leo's collection and her household members. { const token = await login(HOUSE_VIEWER.username); - const b = find(HOUSE_VIEWER.q); - await progress(token, b, {position: b.duration * HOUSE_VIEWER.at}); - await api(token, 'POST', '/auth/logout'); + try { + const b = find(HOUSE_VIEWER.q); + await progress(token, b, {position: b.duration * HOUSE_VIEWER.at}); + if (!viewerOnly) { + const made = await api(token, 'POST', '/me/collections', {name: KIDS_COLLECTION.name}); + const items = KIDS_COLLECTION.books.map(find).map((x) => ({library_id: library.id, path: x.rel_path})); + await api(token, 'PUT', `/me/collections/${made.collection.id}/items`, {items}); + for (const m of HOUSE_MEMBERS) { + const {member} = await api(token, 'POST', '/me/household/members', { + name: m.name, + kind: m.kind, + access: m.access, + ...(m.access === 'collections' ? {collection_ids: [made.collection.id]} : {}), + }); + house.members.push({...m, id: member.id, username: member.username}); + } + } + } finally { + await api(token, 'POST', '/auth/logout').catch(() => {}); + } } if (viewerOnly) { house.ready = true; @@ -1106,7 +1144,10 @@ async function provisionHouse(house, {viewerOnly = false} = {}) { } }, 15000); house.ready = true; - console.log(` ✓ household: ${names.join(', ')} (${HOUSE_MATES.map((m) => m.username).join(' and ')} share)`); + console.log( + ` ✓ household: ${names.join(', ')} (${HOUSE_MATES.map((m) => m.username).join(' and ')} share); ` + + `${HOUSE_VIEWER.username}'s members ${house.members.map((m) => `${m.username} (${m.kind})`).join(', ')}`, + ); } // Wait until the server reports housemate `username` as `state` (asked as another @@ -1126,32 +1167,35 @@ async function houseState(house, username, state, timeoutMs = 2 * MIN) { async function unprovisionHouse(house) { if (house.beat) clearInterval(house.beat); + // Ellen's members first (deleting her deletes them too; this covers one moved away). + for (const m of house.members) await api(house.admin, 'DELETE', `/admin/users/${m.id}`).catch(() => {}); for (const id of house.users) await api(house.admin, 'DELETE', `/admin/users/${id}`).catch(() => {}); - if (house.shareId) await api(house.admin, 'DELETE', `/admin/shares/${house.shareId}`).catch(() => {}); await api(house.admin, 'POST', '/auth/logout').catch(() => {}); // Every household sign-in is also a "New sign-in" server event, which names no user row - // and so outlives the account: take those out too, or the Events shot lists them. + // and so outlives the account: take those out too, or the Events shot lists them. Ellen's + // member writes (member.create, member.session, ...) are hers in the audit log: out too. + const names = HOUSE_USERNAMES.map((u) => `'${u}'`).join(', '); try { - const names = HOUSE_USERNAMES.map((u) => `'${u}'`).join(', '); sqlite(`DELETE FROM server_events WHERE kind = 'new_device' AND json_extract(data, '$.user') IN (${names});`); + sqlite(`DELETE FROM audit_events WHERE actor_name IN (${names});`); } catch (e) { - console.log(` ! household sign-ins left in the server's event feed: ${e.message}`); + console.log(` ! household sign-ins or member writes left in the server's feeds: ${e.message}`); } console.log(' ✓ household accounts removed again'); } -// Settings > Household and sharing, by its link. A reload is a launch, and with two -// people on the device it asks "Who's listening?" first: close it. +// The Household page, by its route. A reload is a launch, and with two people on the +// device it asks "Who's listening?" first: close it. async function openHousehold(page) { - await page.goto(BASE + 'settings?section=household', {waitUntil: 'networkidle', timeout: 45000}); - const pane = tid(page, 'settings-pane-household'); - await pane.or(tid(page, 'profiles-screen')).waitFor({timeout: 15000}); + await page.goto(BASE + 'household', {waitUntil: 'networkidle', timeout: 45000}); + const household = tid(page, 'household-page'); + await household.or(tid(page, 'profiles-screen')).waitFor({timeout: 15000}); if (await tid(page, 'profiles-close').count()) await tid(page, 'profiles-close').click({timeout: 8000}); - await pane.waitFor({timeout: 15000}); + await household.waitFor({timeout: 15000}); await sleep(SETTLE_MS); } -// A person's card in the Household pane, by the name it shows. +// A person's card in "On this device", by the name it shows. const personCard = (page, name) => firstVisible( page.locator('[data-testid^="household-person-"]').filter({has: page.getByRole('heading', {name, exact: true})}), @@ -1159,12 +1203,31 @@ const personCard = (page, name) => // Rename a person on this device (Household): usernames are lower case, people aren't. async function renamePerson(page, from, to) { - await firstVisible(page.getByRole('button', {name: `Rename ${from}`})).click({timeout: 8000}); + await personCard(page, from).getByRole('button', {name: `Rename ${from}`}).click({timeout: 8000}); await firstVisible(page.getByTestId('rename-field')).fill(to); await firstVisible(page.getByRole('button', {name: 'Save', exact: true})).click({timeout: 8000}); await personCard(page, to).waitFor({timeout: 8000}); } +// Scroll `locator` to the top of its scroller and leave the pointer out of the way. +async function scrolledTo(page, locator) { + await locator.evaluate((el) => el.scrollIntoView({block: 'start'})); + await sleep(SETTLE_MS); + await page.mouse.move(0, 0); +} + +// Shoot one element (a card) with a margin round it, after scrolling it into view. +async function shootElement(page, locator, file, margin = 16) { + await locator.evaluate((el) => el.scrollIntoView({block: 'center'})); + await sleep(SETTLE_MS); + await page.mouse.move(0, 0); + const box = await locator.boundingBox(); + if (!box) throw new Error(`nothing to shoot for ${file}`); + await shoot(page, file, { + clip: {x: box.x - margin, y: box.y - margin, width: box.width + 2 * margin, height: box.height + 2 * margin}, + }); +} + // The kids screen, choosing the child on "Who's listening?" when a launch asks first, with // the story loaded and paused (a still frame). async function kidsStaged(page, kidName) { @@ -1198,15 +1261,36 @@ async function kidsStaged(page, kidName) { await page.mouse.move(0, 0); } +// A second context on the household browser's storage (the same device: its people, +// PINs and kids mode), with `options` on top. `light`: the app's own theme pick is light +// too (it keeps one in storage, which wins over the browser's scheme). +async function sameDevice(ctx, options = {}, {light = false} = {}) { + const storageState = await ctx.storageState(); + const other = await newCaptureContext({...options, storageState, ...(light ? {colorScheme: 'light'} : {})}); + if (light) { + await other.addInitScript(() => { + try { + // The app's storage holds JSON values. + localStorage.setItem('audiosilo.theme', JSON.stringify('light')); + } catch { + /* storage blocked: the browser's scheme decides */ + } + }); + } + return other; +} + async function captureHouse(house) { const timezoneId = eveningZone(); const origin = new URL(BASE).origin; const ctx = await newCaptureContext({timezoneId}); - const kidsName = HOUSE_DEVICE.find((p) => p.kids).name; + const kid = house.members.find((m) => m.kind === 'kid'); + const kidsName = KIDS_MEMBER.name; try { const page = await ctx.newPage(); - // Ellen on a fresh browser, then everyone else with "Add someone" (each new person - // becomes the one listening), each renamed. + // Ellen on a fresh browser, then Jamie with "Sign in someone who already has an + // account" (a new person, who becomes the one listening), each renamed; back to + // Ellen, who gives Jamie a PIN and signs Leo in from his member card. let peopleReady = false; await step('household: people', async () => { await page.goto(BASE + 'connect', {waitUntil: 'networkidle', timeout: 45000}); @@ -1216,14 +1300,13 @@ async function captureHouse(house) { await openHousehold(page); await renamePerson(page, HOUSE_VIEWER.username, HOUSE_VIEWER.name); for (const p of HOUSE_DEVICE) { - await firstVisible(page.getByRole('button', {name: 'Add someone', exact: true})).click({timeout: 8000}); + await tid(page, 'household-sign-in-existing').click({timeout: 8000}); await probeServer(page, origin); await signInWithPassword(page, p.username, HOUSE_PASSWORD); await tid(page, 'top-bar-profile').waitFor({timeout: 15000}); await openHousehold(page); await renamePerson(page, p.username, p.name); } - // Back to Ellen, who sets up the others: a PIN, kids mode on one library. await firstVisible(page.getByRole('button', {name: `Switch to ${HOUSE_VIEWER.name}`})).click({timeout: 8000}); await tid(page, 'top-bar-(home)').waitFor({timeout: 15000}); await openHousehold(page); @@ -1235,41 +1318,135 @@ async function captureHouse(house) { await field.pressSequentially(p.pin, {delay: 50}); await field.waitFor({state: 'hidden', timeout: 15000}); } - for (const p of HOUSE_DEVICE.filter((x) => x.kids)) { - await firstVisible(page.getByRole('switch', {name: `Kids mode for ${p.name}`})).click({timeout: 8000}); - // The library the admin's share leaves the account (picked, not the fallback). - await personCard(page, p.name).getByRole('button', {name: 'Library', exact: true}).click({timeout: 8000}); - await firstVisible(page.getByRole('option', {name: 'Books', exact: true})).click({timeout: 8000}); + // The members Ellen signs in here: a profile of their own (a kid's in kids mode), + // Ellen still the one listening. + for (const m of house.members.filter((x) => x.here)) { + await tid(page, `member-here-${m.id}`).click({timeout: 15000}); + await personCard(page, m.name).waitFor({timeout: 15000}); + // Its toast ("Leo is signed in on this device too.") goes before any shot. + const toastLine = page.getByText(`${m.name} is signed in on this device too.`).first(); + await toastLine.waitFor({timeout: 8000}).catch(() => {}); + await toastLine.waitFor({state: 'hidden', timeout: 15000}); } // Ellen shares (the switch settles on the server's answer). const share = /^Share what I'm listening to with the house/; await firstVisible(page.getByRole('switch', {name: share})).click({timeout: 8000}); await firstVisible(page.getByRole('switch', {name: share, checked: true})).waitFor({timeout: 8000}); - console.log(` ✓ ${[HOUSE_VIEWER, ...HOUSE_DEVICE].map((p) => p.name).join(', ')} on this device`); + console.log(` ✓ ${[HOUSE_VIEWER, ...HOUSE_DEVICE, ...house.members.filter((m) => m.here)].map((p) => p.name).join(', ')} on this device`); peopleReady = true; }); // A household set up half-way would shoot the wrong state over the committed shots: // they keep their previous images instead. if (!peopleReady) return; - if (wanted(HOUSE_SHOTS.household)) { - // The kids profile's card at the top of a taller window: its kids mode settings, - // then "Ask who's listening" and the sharing switch under it. - await step('household: settings', async () => { + if (wanted(HOUSE_SHOTS.device)) { + // The kid's card in "On this device" at the top of a taller window: kids mode and + // bedtime, then "Ask who's listening" under it. + await step('household: on this device', async () => { await page.setViewportSize({width: 1440, height: 1000}); try { - await personCard(page, kidsName).evaluate((el) => el.scrollIntoView({block: 'start'})); - await sleep(SETTLE_MS); - await page.mouse.move(0, 0); - await shoot(page, HOUSE_SHOTS.household); + await scrolledTo(page, personCard(page, kidsName)); + await shoot(page, HOUSE_SHOTS.device); } finally { await page.setViewportSize({width: 1440, height: 900}); } }); } + if (wanted(HOUSE_SHOTS.member) && kid) { + // Leo's member card, signed in here (so marked On this device, with this device in + // Their devices), in a window tall enough for all of it. + await step('household: member card', async () => { + await page.setViewportSize({width: 1440, height: 1200}); + try { + const card = tid(page, `member-${kid.id}`); + await card.getByText('This device', {exact: true}).waitFor({timeout: 15000}); + await shootElement(page, card, HOUSE_SHOTS.member); + } finally { + await page.setViewportSize({width: 1440, height: 900}); + } + }); + } + + if (wanted(HOUSE_SHOTS.page)) { + // The same page in light, scrolled to "Your household": the member cards, Add + // someone to your household with its count, then Listening in the house (a window + // tall enough for all of it). + await step('household: page (light)', async () => { + const light = await sameDevice(ctx, {viewport: {width: 1440, height: 1650}, timezoneId}, {light: true}); + try { + const p = await light.newPage(); + await openHousehold(p); + await firstVisible(p.getByRole('button', {name: 'Add someone to your household'})).waitFor({timeout: 15000}); + await scrolledTo(p, tid(p, 'household-members')); + await shoot(p, HOUSE_SHOTS.page); + } finally { + await light.close(); + } + }); + } + + if (wanted(HOUSE_SHOTS.phonePage)) { + // The Me tab's Household segment on a phone (the same device's storage). + await step('household: phone', async () => { + const phone = await sameDevice(ctx, {viewport: {width: 430, height: 932}, timezoneId}); + try { + const p = await phone.newPage(); + await p.goto(BASE + 'you?section=household', {waitUntil: 'networkidle', timeout: 45000}); + const household = tid(p, 'household-page'); + await household.or(tid(p, 'profiles-screen')).waitFor({timeout: 15000}); + if (await tid(p, 'profiles-close').count()) await tid(p, 'profiles-close').click({timeout: 8000}); + await household.waitFor({timeout: 15000}); + await sleep(SETTLE_MS); + await p.mouse.move(0, 0); + await shoot(p, HOUSE_SHOTS.phonePage); + } finally { + await phone.close(); + } + }); + } + + if (wanted(HOUSE_SHOTS.addKid)) { + // "Add someone to your household" for a kid: a name typed, Kids account on, Only + // collections I share with "'s books" chosen (created on save). Cancelled: the + // form saves nothing. + await step('household: add a kid', async () => { + await openHousehold(page); + await firstVisible(page.getByRole('button', {name: 'Add someone to your household'})).click({timeout: 8000}); + await tid(page, 'add-member-name').fill(ADD_KID_NAME); + await tid(page, 'create-member-books').click({timeout: 15000}); + await tid(page, 'pick-new-books').waitFor({timeout: 8000}); + await sleep(SETTLE_MS); + await page.mouse.move(0, 0); + await shoot(page, HOUSE_SHOTS.addKid); + await firstVisible(tid(page, 'add-member-dialog').getByRole('button', {name: 'Cancel', exact: true})).click({timeout: 8000}); + await tid(page, 'add-member-dialog').waitFor({state: 'hidden', timeout: 8000}); + }); + } + + if (wanted(HOUSE_SHOTS.addGrownUp)) { + // A grown-up added (Kids account off: Everything I can listen to), at the code step: + // "Show Ellis this code", the QR and "They'll also use this device". + await step('household: add a grown-up', async () => { + await openHousehold(page); + await firstVisible(page.getByRole('button', {name: 'Add someone to your household'})).click({timeout: 8000}); + await tid(page, 'add-member-name').fill(ADD_GROWNUP.name); + await tid(page, 'add-member-kids').click({timeout: 8000}); + await tid(page, 'access-all').waitFor({timeout: 8000}); + await tid(page, 'add-member-submit').click({timeout: 8000}); + await firstVisible(page.getByText(`Show ${ADD_GROWNUP.name} this code`)).waitFor({timeout: 15000}); + await tid(page, 'add-grownup-also-here').waitFor({timeout: 8000}); + await tid(page, 'member-pairing-loading').waitFor({state: 'hidden', timeout: 15000}); + await sleep(1500); // the QR draws + await page.mouse.move(0, 0); + await shoot(page, HOUSE_SHOTS.addGrownUp); + await tid(page, 'add-grownup-done').click({timeout: 8000}); + }); + } + if (wanted(HOUSE_SHOTS.profiles)) { await step('household: who is listening', async () => { + await openHousehold(page); await firstVisible(page.getByRole('button', {name: "Who's listening?", exact: true})).click({timeout: 8000}); await tid(page, 'profiles-screen').waitFor({timeout: 8000}); await sleep(SETTLE_MS); @@ -1319,8 +1496,7 @@ async function captureHouse(house) { if (wanted(HOUSE_SHOTS.phoneKids)) { await step('household: phone kids', async () => { // This device's storage, from the live browser (kidsStaged picks the child itself). - const storageState = await ctx.storageState(); - const phone = await newCaptureContext({viewport: {width: 430, height: 932}, timezoneId, storageState}); + const phone = await sameDevice(ctx, {viewport: {width: 430, height: 932}, timezoneId}); try { const p = await phone.newPage(); await kidsStaged(p, kidsName); diff --git a/screenshots/manifest.mjs b/screenshots/manifest.mjs index 6bcb97f..865551b 100644 --- a/screenshots/manifest.mjs +++ b/screenshots/manifest.mjs @@ -32,7 +32,7 @@ export const SHOTS = [ {file: 'web-player/dock-undo.png', capture: 'web', title: 'Web player - Docked bar with Undo', hint: 'the docked player bar (a strip along the window bottom) just after a next-chapter jump: the Back to