diff --git a/docs-developers/architecture/cross-repo-contract.md b/docs-developers/architecture/cross-repo-contract.md index 3b7811f..660306a 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,57 @@ 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** (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`, `client.ts` and `hooks.ts` + ([State and data](../frontend/state-and-data.md#household-members)). +- The `user` object (`/me` and every session envelope) gains `managed_by` (`{ id, + 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 console's fields and settings stay inside the server repo + ([Admin: users](../server/api/reference.md#get-apiv1adminusers)). + +**Server rules** (each with an allowed and a denied test; +[Auth & security](../server/auth-and-security.md#household-members)): what a member may +listen to (the owner's access, or the collections the owner shares, plus their own +grants), one level and never an admin, the kid refusals (`403 kid_account`), kids out +of share targets and Listening in the house, someone else's member `404` like no such account, a +disabled member's session and pairing code `409 member_disabled`. + +**Client rules** ([Household page and members](../frontend/household.md)): a member +the owner signs in on this device from the Household page 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. +Refusals are read by `code`, never by the server's message +([Error mapping](../frontend/state-and-data.md#the-api-client-srcapiclientts)), so a new +`code` 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 +server. An older server has no flag, so the player offers no "Add someone to your +household" and profiles and kids mode work 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/architecture/invariants.md b/docs-developers/architecture/invariants.md index a04b814..9970701 100644 --- a/docs-developers/architecture/invariants.md +++ b/docs-developers/architecture/invariants.md @@ -177,7 +177,9 @@ audiobooks" into "remote file read on your NAS". - **Path safety**: `library.SafeJoin` rejects traversal outside the library root; every filesystem access derived from user input goes through it. Share scoping (`Scope.Allows`, `Scope.VisibleInBrowse`, `pathFilterSQL`) authorizes every - content path against the caller's grants (`authorizedPath` in the handlers). + content path against the caller's grants (`authorizedPath` in the handlers), + a household member's derived from their owner's by the + [member rule](../server/auth-and-security.md#household-members). - **Scoped CSP**: the admin/connect pages keep a strict same-origin CSP with no inline script/style at all; the web player at `/web` gets a per-document CSP whose `script-src` carries a sha256 hash of that document's inline scripts diff --git a/docs-developers/contributing/documentation.md b/docs-developers/contributing/documentation.md index efa9da3..349d281 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/`, `src/components/household/device-people.tsx`) | [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/household.md](../frontend/household.md) + [state-and-data.md](../frontend/state-and-data.md#household-members) + [kids-mode.md](../frontend/kids-mode.md) + [you-and-settings.md](../frontend/you-and-settings.md#account-srccomponentsaccount) (a member's account page, the lost-phone tick) + the [reference](../server/api/reference.md#household-members) + [api/index.md](../server/api/index.md#error-envelope-and-status-conventions) (the codes and the capability) + [auth-and-security.md](../server/auth-and-security.md#household-members) + [data-model.md](../server/data-model.md#accounts--credentials) + [server/configuration.md](../server/configuration.md#household-members-household) + [backups-and-notifications.md](../server/backups-and-notifications.md#audit-log) (the `member` audit area) + 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`, `/users/listening/profiles`, `/users/admin/users-and-invites#household-members`, `/users/admin/server` (Household, the audit log) | +| Kids mode (`src/components/kids/`, the bedtime rule and the kids 1× speed in `src/playback/listener-settings.ts`, applied by `auto-sleep-controller.ts`, the kids car snapshot in `src/car/`) | [frontend/kids-mode.md](../frontend/kids-mode.md) (+ [frontend/sleep-timer.md](../frontend/sleep-timer.md) for the auto sleep rule) + `/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/contributing/gates-and-ci.md b/docs-developers/contributing/gates-and-ci.md index 0e60764..e7868a9 100644 --- a/docs-developers/contributing/gates-and-ci.md +++ b/docs-developers/contributing/gates-and-ci.md @@ -133,7 +133,8 @@ the repo: - **Security-critical code requires both an allowed *and* a denied regression test** (the denied one is the point). That covers anything touching `library.SafeJoin`, `Scope.Allows` / `VisibleInBrowse` / `pathFilterSQL`, the - rate limiters, `auth.ResolveRequest`/`lookupToken`, `web.htmlCSP`, or `bearerToken`. See + household member rule (`catalog/members.go`) and member routes + (`handlers_members.go`), the rate limiters, `auth.ResolveRequest`/`lookupToken`, `web.htmlCSP`, or `bearerToken`. See [auth and security](../server/auth-and-security.md). ### Frontend (TypeScript) diff --git a/docs-developers/frontend/connect-and-addresses.md b/docs-developers/frontend/connect-and-addresses.md index 607bad3..00ecdc2 100644 --- a/docs-developers/frontend/connect-and-addresses.md +++ b/docs-developers/frontend/connect-and-addresses.md @@ -61,7 +61,22 @@ 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 (see +[Kids mode](kids-mode.md#the-route-and-its-guard)). A kid's account placed for the +first time on this device starts in kids mode (`kidsOnForKidAccount`: see +[Kids mode](kids-mode.md)). Two sign-ins skip `finishConnect`: the public demo +landing (`src/app/demo.tsx`, which calls `setSession` itself so its QR stays on +screen), and a household member signed in beside the owner (`addMemberHere`, or +`renewMemberHere` when signed in again; see +[Signing a member in here](household.md#signing-a-member-in-here)). `repairPlan` decides what a sign-in stores: diff --git a/docs-developers/frontend/household.md b/docs-developers/frontend/household.md new file mode 100644 index 0000000..85d37e4 --- /dev/null +++ b/docs-developers/frontend/household.md @@ -0,0 +1,173 @@ +--- +title: Household page and members +description: "The Household page and household members in the player: the page and every way in, the members section, adding a member, a member's card, signing a member in beside the owner (addMemberHere, signInMemberHere, renewMemberHere), and how the app names a member's owner." +--- + +The **Household page** is where a person looks after the people they listen with: the +people on this device ([Profiles](profiles.md#on-this-device-devicepeople)), the +household members they added on their servers, and +[Listening in the house](listening-in-the-house.md). A **household member** is a real +account on the server (capability `household_members`), so what it may do is the +server's to decide (the +[cross-repo contract](../architecture/cross-repo-contract.md#16-household-members-profiles-and-kids-mode-stay-client-side)); +the wire is [Household members](../server/api/reference.md#household-members) and its +client side [State and data](state-and-data.md#household-members). On the device a +member is just another connection in a profile of its own. + +The user-facing side is [Your household](/users/listening/household). + +## The page + +`HouseholdPage` (`src/components/household/household-page.tsx`) is 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 to the pushed page** 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. In the phone You hub, the segmented +control and the embedded Settings' link (`SettingsContent`'s `onOpenHousehold`) switch +to the Household segment with `router.setParams` instead. `/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`** ("On this device"): see + [Profiles](profiles.md#on-this-device-devicepeople). +- **`HouseholdMembers`** (`src/components/household/members-section.tsx`), below. +- **`HouseSharing`** (`house-sharing.tsx`): the per-server house sharing switches + ([Listening in the house](listening-in-the-house.md)). + +## The members section + +`HouseholdMembers` renders, per connection of the person listening whose server has +`household_members`, the members list (`useHouseholdMembers`), a `MemberCard` each and +**Add someone to your 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 ("Adam manages your account.", "Only +Adam can add people or change what you can listen to."; see +[Naming the owner](#naming-the-owner)). The pure rules are `household-model.ts` (the +name rule, the default access per kind, `collectionIdsFor`, the error mapping, +`memberConnection`, `confirmsName`). + +**Refusals** are read by kind with `apiErrorKind` (see +[Error mapping](state-and-data.md#the-api-client-srcapiclientts)) and said in the app's +own words: `writeError` / `writeErrorText` for the member writes, `hereFailure` for +signing a member in here. + +## 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 member's card + +`MemberCard` carries: + +- what they can listen to, and its change dialog (the same picker; moving + `collections` to `all` sends `collection_ids: []` through `collectionIdsFor` 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`), and "Sign in on this device" while they + aren't here; +- Rename (the server's name and, when they are here, their profile's); +- "Delete their account" (`ConfirmDialog`, then typing the name, `confirmsName`), kept + apart from **Remove from this device** (shown while they are here; see + [Profiles](profiles.md#on-this-device-devicepeople)). + +## Signing a member in here + +`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: + +- 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 + (see [Session](state-and-data.md#session-srcstoressessionts)); +- `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; +- a kid's account starts in kids mode with the default bedtime + (`kidsOnForKidAccount`: see [Kids mode](kids-mode.md)). + +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. + +### Signing a member in again + +A person on this device 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(session)`** (in +`add-member-here.ts`), at once and with no password: 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). Otherwise it is +`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). + +`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, and a kid's shelf recovers without a reload. + +## Naming the owner + +Copy that names a member's owner (the members section, kids mode's lines, the password +card, the sign-out warnings, the remove confirmation) goes through +`managerName(user, { connection, people })` in `src/lib/account.ts`, or +`useManagerName(connectionId, user?)` in `src/stores/profiles.ts` (with +`useDevicePeople()`), never `managed_by.username` directly: the name of the profile +holding the owner's own sign-in to that server on this device, else the owner's +username (the player's `managed_by` carries no typed name; `managerName` would read a +`display_name` there only if a server sent one). `kidsManager(profile, people)` is the +same rule for a kids profile's connection. + +## Testing + +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/`; the member hooks in +`src/api/hooks-members.test.tsx`; the page's routes in +`src/components/shell/route-tree-household.test.tsx` and +`route-tree-household-cold.test.tsx`; `src/api/provider.test.tsx` (read again on a new +token). diff --git a/docs-developers/frontend/kids-mode.md b/docs-developers/frontend/kids-mode.md index 471d978..5006b79 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, a property of a profile: on by default for a kid's account, the kids route and its guard, the one gate between kids mode and the connect flow, the stories it shows (the owner's shared collections, else one library), a kid signed out, the kids player at normal speed, the Grown-ups hold, the car, and bedtime." --- 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](household.md). It is +client-side: the server has no notion of it. The child's account is either a **kid +household member** (`user.member_kind === 'kid'`, made by an owner from +the player, its access decided by the server's member rule; see +[Household members](../server/api/reference.md#household-members)) or an ordinary +account whose library access the admin limited. 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,22 @@ 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. 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. @@ -32,20 +50,29 @@ 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**. 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. @@ -59,21 +86,63 @@ 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`): 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: + `useKidsStories` reads each one's `GET /me/collections/{id}`, and the pure + `kidsCollectionSections` gives one section per collection with stories, titled with + the collection's name, 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); with one section the shelf shows no title (`useKidsStories` passes `sections` + only when there are several). 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. +- **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 it through + `useKidsSharedShelf(profile)` (`shared`, `library` or `unknown`) and shows the kids + **Library** row only for `library`: a one-library shelf, and also while a member's + shelf isn't known yet (the row goes once the shared collections arrive). It is + hidden for a shelf of collections, and while the kid is signed out or reading the + collections failed (`unknown`). + When an owner switches a member to `all`, the shares stop + ([a member's card](household.md#a-members-card)), so the kid's shelf is one library again. +- **The library** (`kidsLibrary`), for everyone else (an account nobody manages, an + 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 - 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 Household page's kids rows for a member's profile name the owner ("The stories +Adam shares…", "Shown when Adam shares no collections") through `kidsManager(profile, +people)` (see [Naming the owner](household.md#naming-the-owner)). 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** +([Signing a member in again](household.md#signing-a-member-in-again)), and 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`). @@ -81,7 +150,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. @@ -92,16 +164,28 @@ 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. -## Bedtime (`src/playback/auto-sleep-controller.ts`) +## In the car + +A kids profile's CarPlay and Android Auto snapshot is the kids shelf only +(`kidsCarBooks` in `src/car/car-model.ts`): a **Library** tab titled "Your stories" and +**Continue listening**, nothing else the account could reach. The spec is in +[Native integrations](native-integrations.md#the-car-snapshot-srccar). + +## 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 @@ -116,8 +200,11 @@ running timer has left. ## Testing -`kids-model.test.ts` (the rules above), `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 26f984c..449010d 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,15 @@ 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](household.md) (`HouseSharing`) 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`). `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 7f0e1ea..c88b464 100644 --- a/docs-developers/frontend/native-integrations.md +++ b/docs-developers/frontend/native-integrations.md @@ -67,6 +67,17 @@ 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** gets the kids shelf only + (`kidsCarBooks`): the `library` tab holds the shelf as [kids mode](kids-mode.md#what-it-shows-srccomponentskids) + 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`, 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. - 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..0ccd53f 100644 --- a/docs-developers/frontend/overview.md +++ b/docs-developers/frontend/overview.md @@ -61,7 +61,9 @@ 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, the kids library picker, + removing a person), household/ (the Household page: On this + device, 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,14 +147,15 @@ 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`): 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 phone hub's Household segment renders the same page. See [Household page and members](household.md#the-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. | | `/finished` | `src/app/finished.tsx` | The end-credits screen shown when a book finishes (or from the player's menu). A root modal sibling of the player; `connection`/`libraryId`/`path` params, plus `auto=1` when the book ended (or was marked finished) rather than being opened early. Renders `EndCredits`: the year shelf, listening stats, a rating, and the book `resolveUpNext` says plays next. See [The end of a book](end-of-book.md#ending-a-book). | | `/year?year=…&connection=…&card=…` | `src/app/year.tsx` → `src/components/you/year/year-story-screen.tsx` | The phone's full-screen Year in listening story, a root modal like the player (`yearHref`): close, pull down, Escape on the web. See [Year in listening](you-and-settings.md#year-in-listening-srccomponentsyouyear). | -| `/connect` layout | `src/app/connect/_layout.tsx` | Onboarding stack (a spinner while the session hydrates). It deliberately does not decide redirects: on a link arriving while the app runs, the child's params only reach the layout after the child mounts. | +| `/connect` layout | `src/app/connect/_layout.tsx` | Onboarding stack, and the one gate between the flow and kids mode: a spinner until both the session and the profiles are read; while a kids profile listens (unless `kidsConnectAllowed()`) it runs none of the flow and `dismissTo`s `/kids` (see [Kids mode](kids-mode.md#the-route-and-its-guard)). It does not decide the signed-in bounce: on a link arriving while the app runs, the child's params only reach the layout after the child mounts, so `connect/index.tsx` decides it. | | `/connect` | `connect/index.tsx` → `src/components/connect/connect-start.tsx` | The first onboarding step: the server address with its probe ("Found <name>", Sign in, Try the demo), Scan a QR code (native) or a pasted pairing link (web), a pairing `token` arriving by deep link or QR `web_url` exchanged at once, and one-tap **Reconnect** rows for remembered servers the person listening isn't signed in to (`knownToOffer` over `useProfileConnections`; for Add someone, `?profile=new`, every remembered server) (`src/lib/known-servers.ts`). An **authenticated** user is bounced home from here, from this route's own params, unless they are adding another server (`?add=1`, a pairing `?token=`, or a sign-in mid-flow via `pendingServerUrl`); decided when it opens and when it is back on top. Onboarding returns to the app through `leaveOnboarding()` (`src/components/shell/leave-onboarding.tsx`, which calls `router.dismissTo('/')`; `` when the decision is made at render time), never `replace` or `` (also a replace): `(app)` already sits under `/connect` as the root stack's anchor, so a replace stacked a second `(app)`. See [Connect and home/away addresses](connect-and-addresses.md#connect-and-onboarding). | | `/connect/scan` | `connect/scan.tsx` | Camera QR scanner (`expo-camera`) for the pairing QR. | | `/connect/sign-in` | `connect/sign-in.tsx` → `src/components/connect/sign-in-step.tsx` | Invite code **or** username and password against the pending server (`pendingServerUrl`, read once). Reached from the first step, a **Reconnect** row, or the dead-token `ReconnectBanner` (`src/components/layout/reconnect-banner.tsx`, which passes `reconnect=` and the address in use now). The code field redeems invite codes (legacy recovery codes still redeem server-side, but the app no longer mints them). Every sign-in ends in `finishConnect`. | @@ -221,7 +224,7 @@ still jumps to the Search tab instead. it), then **Go to** (`buildGoToItems`: `TOP_BAR_TABS`, already filtered to what this browser can do, but You, which goes by its sections instead - Your listening and Year in listening (`openYou`), the Journal (`openJournal`) - then Settings - (`openSettings`)). Empty groups are dropped. A series opens `openSeries` with its + (`openSettings`) and Household (`openHousehold`)). Empty groups are dropped. A series opens `openSeries` with its local name, a person their page, a character the book it is from. The module also owns the arrow-key clamp (`moveSelection`, no wrap), the recent list (`addRecent`), the shortcut test (`isPaletteShortcut`) and the key hint (`shortcutHint`: ⌘K on Apple platforms, Ctrl K elsewhere). @@ -331,7 +334,7 @@ button (the person listening's initial, plus their name on desktop) and a `Dropd server of the person listening with its state - the pure `serverStatus()` (`src/api/reachability.ts`), where "needs signing in again" (the reconnect flag) wins over "offline", else "Signed in as <user>" - each opening that server's account screen; **Add a server** -(`/connect?add=1`; hidden, as in Settings' accounts pane, when the served web player is signed in only to its own server, `onlyOwnServer(servedOrigin(), ...)`); **Journal** (`openJournal()`); **Settings** (`openSettings()`); **Account on <default server>**; and a light/dark +(`/connect?add=1`; hidden, as in Settings' accounts pane, when the served web player is signed in only to its own server, `onlyOwnServer(servedOrigin(), ...)`); **Journal** (`openJournal()`); **Household** (`openHousehold()`); **Settings** (`openSettings()`); **Account on <default server>**; and a light/dark appearance switch (`useTheme().toggleScheme()`, which the palette's action uses too). A phone keeps all of this in the Me tab's You hub. The same `serverStatus()` drives the top bar's server line (the default server's name, or "Offline" / "Needs @@ -370,7 +373,7 @@ drop Downloads where the platform can't store downloads (`engine.supported`; on web, a secure context with the Cache API). - **The pushing tab owns a detail page.** The shared detail routes (book, - folder, account, settings, journal, favourites, browse, series, author, narrator, collection) + folder, account, settings, household, journal, favourites, browse, series, author, narrator, collection) live once in the array group, and expo-router resolves a push against the current segments, so a book pushed from Search stays in Search and back returns there. @@ -396,8 +399,8 @@ web, a secure context with the Cache API). - **A tab root opened from elsewhere is popped to, not pushed.** `openYou` / `openJournal` (never `pushInShell`, which would push a second hub) close a root modal, pop the Me stack to its root (`popTabToRoot`) and then navigate; - Settings, a page of the array group, is pushed on the current tab instead - (`openSettings`). See [You, Settings and Account](you-and-settings.md#the-hub-and-its-routes). + Settings and Household, pages of the array group, are pushed on the current tab + instead (`openSettings`, `openHousehold`). See [You, Settings and Account](you-and-settings.md#the-hub-and-its-routes). - **Web: `` stays at a fixed ancestor path at every width**; only the sibling chrome toggles. Moving it between wrappers remounts every screen on a resize and jumps the URL to another tab. 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 4f55662..817d078 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: a person on this device as a group of connections. The profiles store and document, the consistency rules, visible and background connections, switching and the play rule, Who's listening?, the device-local PIN, links to another person's book, and the Household page's On this device." --- A **profile is a person on this device**: a device-local group of connections, at @@ -11,8 +11,12 @@ 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. +A [household member](household.md) is a real account on the server, unlike a profile +([the contract](../architecture/cross-repo-contract.md#16-household-members-profiles-and-kids-mode-stay-client-side)); +on the device it is just another connection in a profile of its own. + The user-facing side is -[Sharing a device with your household](/users/listening/profiles) and +[Sharing a device (profiles)](/users/listening/profiles) and [Kids mode](/users/listening/kids-mode); kids mode's code is [its own page](kids-mode.md). @@ -23,7 +27,7 @@ One persisted document, `audiosilo.profiles`, hydrated in `bootstrapPlayback()`: ```ts interface Profile { id: string; // local, random (a UUID); never sent anywhere - name: string; // defaults to a connection's username; editable here + name: string; // defaults to personName(user): a member's display_name, else the username; editable here connectionIds: string[]; // at most one per server kids?: { library?: { connectionId: string; libraryId: number }; bedtime: string /* 'HH:MM' */ }; hasPin?: boolean; // mirrors the secure store; the hash lives there @@ -95,6 +99,13 @@ 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 +([`addMemberHere`](household.md#signing-a-member-in-here)) `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 `personName(user)`: the member's `display_name`, else the username). + ### Visible and background connections The rule that keeps one person's state from leaking into another's view without @@ -104,8 +115,10 @@ breaking their background work: (`useProfileConnections`, `profileConnections`, and the cross-connection hooks' `useApis()`): Home, the Library and its picker, Search and the command palette, the Downloads page, Up next, You (stats, Year, Journal), Settings and the profile menu, - [Listening in the house](listening-in-the-house.md), the car snapshot and the - widget. Never `useSession((s) => s.connections)` for a list someone sees. + the Household page's members, [Listening in the house](listening-in-the-house.md), + the car snapshot and the widget. Never `useSession((s) => s.connections)` for a list + someone sees, except a list of the device's people (Who's listening? and On this + device show every person's servers). - **Background work keeps using every connection**: the offline save queue, the downloads engine, car bookmarks waiting to be sent, address routing, reachability. A switch must never make another person's queued saves unroutable (the queue flush @@ -152,14 +165,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](household.md) + (`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`) @@ -174,19 +189,19 @@ anyone with the device and a browser's developer tools can read a session token is 16 random bytes; `hash` is SHA-256 over salt + PIN, then over salt + the previous 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 +- **The secure store decides** (`readPinMirrored`, which reads it with `readPin` and + repairs the profile's `hasPin` mirror to match, in `useChooseProfile` and in the + Household page's **Set a PIN**, **Change** and **Remove**): 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 - secure store) counts as no PIN and is cleared when someone switches to that person. - **When the secure store can't be read, the mirror decides**: a person marked as having - a PIN is still asked (the prompt says it couldn't check), a person not marked is - switched to without one, and **Set a PIN** goes straight to choosing a new one. **Set a - PIN** over a hash the secure store can read asks for the current one first. The pane's - **Change** and **Remove** are offered from the mirror alone and check against the - secure store, so with the hash gone no PIN matches there ("That PIN doesn't match") - until a switch to that person, or removing them, clears the mirror. + holding an older document can leave behind. A stored hash the mirror lost still asks; + a mirror whose hash is gone (an install restored without its secure store) counts as + no PIN. **When the secure store can't be read, nothing acts on the mirror's word**: + choosing that person doesn't switch, and **Set a PIN**, **Change** and **Remove** + change nothing; each says "Couldn't check the PIN on this device. Try again." **Set a + PIN** over a hash the secure store can read asks for the current one first. **Change** + and **Remove** are offered from the mirror; with the hash gone they repair it and say + "There's no PIN set for <name> on this device." instead of asking for a PIN + nothing could match. - **SHA-256 in plain JS** (`src/lib/sha256.ts`, synchronous, checked against the standard's vectors): 10,000 rounds through `expo-crypto` would be 10,000 asynchronous native calls, and on the web `crypto.subtle` exists only in a secure context, so a @@ -220,28 +235,32 @@ 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 +## On this device (`DevicePeople`) -`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](household.md)'s first part is `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 `useKidsSharedShelf` says `library` (a one-library shelf, or a member's shelf not +known yet), `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`: see [Signing a member in again](household.md#signing-a-member-in-again)). **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. Removing the +person listening leaves nobody chosen. ## 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`; the Household page's own in [Household](household.md#testing); 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 d0838d5..b875dea 100644 --- a/docs-developers/frontend/state-and-data.md +++ b/docs-developers/frontend/state-and-data.md @@ -20,8 +20,17 @@ themselves. `{ history }`); the client methods unwrap them and default `null` to `[]`, so callers always get plain arrays. Auth returns `{ token, user }`; `/me` returns the user directly. -- **Error mapping.** Any non-2xx throws `ApiError(status, message)` carrying - the server's `{ error }` string. A request that exceeds the client timeout +- **Error mapping.** Any non-2xx throws `ApiError(status, message, code?)` + carrying the server's `{ error }` string and, when the route gives one, its + machine-readable `code`. Screens branch on `apiErrorKind(e)`, never on the + message (the server's English, never shown): the code when it is one the app + knows (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` for anything else or no answer), so an older + server without codes still reads sensibly. A new code needs a case in the list + and its words in the screens, or it reads by its status. A request that exceeds the client timeout (default 15 s, enforced with an internal `AbortController`) throws `TimeoutError` - deliberately distinct from the `AbortError` a caller-supplied signal raises, so the reachability layer can classify a @@ -348,6 +357,59 @@ 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 + +Capability `household_members`; the wire is +[Household members](../server/api/reference.md#household-members), the screens +[Household page and members](household.md). 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`), +`member_kind` (null for a non-member, absent on an older server) and `display_name` (a +member's name as the owner typed it, absent for everyone else; `personName` names a new +profile after it), `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; 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** ([Error mapping](#the-api-client-srcapiclientts)). +- **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 session the owner mints for this device** (the add's `session`, + `useMemberSession`) **is placed with `addMemberHere`** (`renewMemberHere` for a + member already here), never `finishConnect` (see + [Signing a member in here](household.md#signing-a-member-in-here)): the owner stays + the person listening and the default connection doesn't move. A member pairing their + own device from the owner's code goes through `finishConnect` like anyone else. +- **What a member may open is the server's answer.** The player reads `member_kind` + and `managed_by` only to shape its own screens, never to filter: `member_kind` + (`isKidAccount`) starts kids mode by default, lets a kid's own profile be in kids + mode with no grown-up (`mayTurnOnKids`) and leaves out what a kid's account can't do + (the password button, Pair another device, API keys, house sharing); `managed_by` + picks the kids shelf's collections ([Kids mode](kids-mode.md)), names the owner + (`managerName`) and finds the owner's sign-in that can renew the member's session + (`memberOwnerHere`). + ### Bookmarks, notes and the Journal The server's `annotations` capability, mirrored like the 1b state: every hook takes an @@ -664,7 +726,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/testing.md b/docs-developers/frontend/testing.md index 6d283ac..25888f9 100644 --- a/docs-developers/frontend/testing.md +++ b/docs-developers/frontend/testing.md @@ -326,8 +326,9 @@ 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` | -| 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` | +| 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/shell/auth-gate.test.tsx`, `profile-menu.test.tsx` | +| Household page and members | `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/connect/add-member-here.test.ts`; `src/api/hooks-members.test.tsx` (gating, what each write refreshes, `alsoMembers`); `src/components/shell/route-tree-household.test.tsx` and `route-tree-household-cold.test.tsx` (the pushed page, the old `/settings?section=household` link, a cold link); `src/api/provider.test.tsx` (read again on a new token) | +| 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 connect flow's gate in `src/components/connect/connect-layout.test.tsx`; 1× and the bedtime rule in `src/playback/listener-settings.test.ts`, the bedtime regressions 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` | | Home | `src/components/home/home-model.test.ts`, `previously-on-model.test.ts`, `previously-on.test.tsx`, `listening.test.ts`, `now-card-model.test.ts`, `now-card.test.tsx`, `home-screen.test.tsx` | diff --git a/docs-developers/frontend/you-and-settings.md b/docs-developers/frontend/you-and-settings.md index 5c1f4f1..7aac69f 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,25 @@ 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/` ([Household page and members](household.md)) | | 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). 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". @@ -38,7 +40,7 @@ re-export of `YouHub` (`src/components/you/you-hub.tsx`). The rules are the pure The section control is `SubNavSections tab="(me)"`, so on tablet and desktop it is published into the sub-nav ([Sub-nav sections and actions](overview.md#sub-nav-sections-and-actions)). -Stats, Year and Account are plain columns with no scroller or gutters of their own: the +Stats, Year, Household and Account are plain columns with no scroller or gutters of their own: the hub puts them in a `TabPageScroll` (`src/components/shell/tab-page-scroll.tsx`: the page gutters, the mini player's room and the iOS tab bar's inset). The Journal and Settings bring their own scrolling, through the same scroller. @@ -61,6 +63,10 @@ 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 phone hub's Household segment renders the same `HouseholdPage`. See +[The page](household.md#the-page) for every way in. + **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 +176,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. `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 (see + [Household page and members](household.md)). - `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 @@ -210,9 +219,9 @@ Each block is gated on its capability, so an older server shows less, quietly: |---|---|---| | Identity: name, "@user · role on server", the signed-in device count | `IdentityCard` | the count needs `my_devices` | | Password: set or change in a dialog that lifts above the keyboard | `PasswordCard`, `PasswordDialog`, `usePasswordEditor` | hidden for a demo account | -| Pair another device: `api.pair()`, the QR, a countdown from arrival (`PAIRING_TTL_MS`, the server's 10-minute `pairingTTL`), Copy link on web, Share on native, Make a new code once expired | `PairDeviceCard`, `usePairing` | - | +| Pair another device: `api.pair()`, the QR, a countdown from arrival (`PAIRING_TTL_MS`, the server's 10-minute `pairingTTL`), Copy link on web, Share on native, Make a new code once expired | `PairDeviceCard`, `usePairing` | hidden for a kid's account (`isKidAccount`) | | Signed-in devices: sessions only, this device first (`signedInSessions`), app, platform, last seen (`lastSeen`) | `DevicesSection` | `my_devices` | -| Personal API keys: list, create in a dialog, reveal once, revoke with a confirm | `ApiKeysSection`, `useApiKeysManager`, `ApiKeyCreatedModal` | `api_keys` (a notice when it's `false`); hidden for a demo account | +| Personal API keys: list, create in a dialog, reveal once, revoke with a confirm | `ApiKeysSection`, `useApiKeysManager`, `ApiKeyCreatedModal` | `api_keys` (a notice when it's `false`); hidden for a demo or kid's account | | Sign out of the server | `useSignOut`, `SignOutConfirm` | - | **The current device's row never offers a sign-out**: `canRevoke` refuses `current`, @@ -222,3 +231,24 @@ 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" (`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 Pair another device card (the server refuses a kid's pairing, `403 +kid_account`; the owner sets up its devices from the Household page) and no API keys +section; a grown-up member's Password card names who set the account up. The page hands +the password card and the sign-out confirmation its connection, so they name the owner +as this device knows them (`useManagerName`). The sign-out warning for a member without a +password names their owner instead of an invite (`signOutWarning`). (A kid's house +sharing row, on the Household page, is off and disabled (`house.share.kid`): see +[Listening in the house](listening-in-the-house.md).) diff --git a/docs-developers/server/api/index.md b/docs-developers/server/api/index.md index 61c1470..311953d 100644 --- a/docs-developers/server/api/index.md +++ b/docs-developers/server/api/index.md @@ -59,7 +59,7 @@ records the request's address (`last_ip`) and, when it names one, its app (see [Client identification](#client-identification-x-audiosilo-client)) on the token. -There are three ways to obtain a session token: +There are four ways to obtain a session token: 1. **Auth-code pairing** (the primary flow): `POST /auth/redeem` with an invite or recovery code returns a *pairing payload* - a pairing token plus @@ -73,10 +73,16 @@ There are three ways to obtain a session token: this only works for accounts that have one. 3. **Demo session**: `POST /demo/session` (only when demo mode is enabled) mints a throwaway account and returns a session token immediately. +4. **A household member's session, made by their owner** (capability + `household_members`): `POST /me/household/members` with `sign_in_here`, or + `POST /me/household/members/{id}/session`, returns the member's session envelope, + signed in on the owner's device (see + [Household members](reference.md#household-members)). An already-authenticated client can mint a fresh pairing payload for another device with `POST /auth/pair` - that token is single-use with a 10-minute TTL, -since the user is present and can mint another. See +since the user is present and can mint another (an owner mints one for a member's +own device with `POST /me/household/members/{id}/pair`, alike). See [Auth & security](../auth-and-security.md) for the trust model behind codes, tokens, and hashes. @@ -88,7 +94,8 @@ key satisfies the admin-role check, a regular user's does not. Pairing tokens are never accepted as a bearer credential, and an API key is never valid for `/auth/exchange`. It also **cannot mint a fresh durable credential** - an API-key caller is refused (403) on the credential-minting routes (create key, recovery, -pair, set password, cast token), so revoking a leaked key cuts off everything it could reach. +pair, set password, cast token, and every household member route), so revoking a +leaked key cuts off everything it could reach. See the [reference](reference.md#personal-api-keys). ### Media requests: `?token=` - media GETs only @@ -190,6 +197,25 @@ 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` (where it is enforced: [Kids](../auth-and-security.md#household-members)) | +| `kid_account` | `409` | the admin's `POST /admin/users/{id}/authcode` or `POST /admin/authcodes/{id}/rotate` for a kid household member's account (a kid holds no invite; only their owner sets up their devices) | +| `member_cannot_manage` | `403` | a household member on every `/me/household/members` route but the list (which answers a member `200`, `allowed: false`, `reason: "member"`) | +| `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` when the request leaves the account a kid (a kid with no household change in it, or an assign as a kid; `""`, clearing, is fine). `POST /auth/password` racing the owner's switch to kid answers `403 kid_account` instead | +| `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 +230,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, a session or pairing code for a member the admin disabled, 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 +348,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 +408,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`, `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 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..6dc0bce 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`, `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`. + ### `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,29 @@ 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"`. 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 while that is empty (a member the admin assigned, until the +owner renames them) and 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` *Session* (any role, demo accounts included). Capability `addresses`. The server's @@ -374,10 +402,21 @@ Either field is omitted when unknown, and the answer is `{}` when neither is. ## Self-service account -The routes in this section, together with the [API keys](#personal-api-keys) +The routes in this section but `DELETE /auth/recovery` (which only reduces access, so +it is neither limited nor refused), 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 (the admin can't +mint or rotate a kid's invite either: `409 kid_account`, below). 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 +(`ErrKidNoPassword`), and the route answers it as `403 kid_account` too. ### `POST /api/v1/auth/password` @@ -394,7 +433,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`, also when the owner's switch to kid commits between the wrapper and the write) | | `429` | account-mutation limit tripped | ### `POST /api/v1/auth/recovery` @@ -416,7 +455,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` @@ -436,13 +475,15 @@ limit (10 attempts per IP per 15 minutes) and are **refused for demo accounts** (403). **Containment.** A key cannot mint a *fresh durable credential*. A request that -authenticates with an API key is refused (**403**) on the five credential-minting +authenticates with an API key is refused (**403**) on the credential-minting routes - `POST /auth/tokens` (another key), `POST /auth/recovery`, -`POST /auth/pair`, `POST /auth/password`, and [`POST /cast/token`](#post-apiv1casttoken) -(refused although a cast token would die with the key) - so revoking a leaked key cuts off -everything it could reach: it can't spawn a key, recovery code, pairing token, or -password that would outlive its own revocation (mirrors GitHub's "a token cannot -create tokens"). It may still **list and revoke** keys and clear a recovery code, +`POST /auth/pair`, `POST /auth/password`, [`POST /cast/token`](#post-apiv1casttoken) +(refused although a cast token would die with the key), and every +[household member](#household-members) route (adding one with `sign_in_here`, +`…/{id}/session` and `…/{id}/pair` make a member's session or pairing code) - so +revoking a leaked key cuts off everything it could reach: it can't spawn a key, +recovery code, pairing token, password or member session that would outlive its own +revocation (mirrors GitHub's "a token cannot create tokens"). It may still **list and revoke** keys and clear a recovery code, since those only reduce access. ### `POST /api/v1/auth/tokens` @@ -474,7 +515,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` @@ -514,7 +555,9 @@ immediately. Response: `204 No Content`. player-side counterpart of the admin's [`GET /admin/devices`](#get-apiv1admindevices): a device is a live session token (a paired phone, a browser) or a personal API key, never a pairing token. These -routes only ever touch the caller's own tokens. +routes only ever touch the caller's own tokens, and, with `?also_members=1`, the +sessions and API keys of the caller's own household members made on the device being +signed out. ### `GET /api/v1/me/devices` @@ -532,7 +575,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 +589,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, 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 +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: @@ -564,11 +624,13 @@ 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 | |---|---| -| `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) | @@ -628,7 +690,8 @@ are untouched. ### `GET /api/v1/libraries` -*Session.* Libraries the caller can reach through any share (admins see all). +*Session.* Libraries the caller can reach through any share, or, for a +[household member](#household-members), through their owner (admins see all). ```json { @@ -2479,23 +2542,29 @@ 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 -the owner's access is shared with them. +the owner's access is shared with them, except with the owner's own +[household member](#household-members) on `access: "collections"`, whose access is +the books of the collections their owner shares 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 +2574,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 +2789,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 +2822,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 +2836,235 @@ 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 who isn't a member +themselves (the **owner**) adds the people they live with from the player, with no +admin and no invite, while the server's and the admin's switches allow it (below). A +**member** is a real account (`role: "user"`, made with no password; a grown-up may +set one later, a kid never holds one) whose `users.managed_by` is the owner, with a +`member_kind` (`kid` or `grownup`) and a `member_access` (`all` or `collections`) that +the owner sets (the admin can too, with +[Assign to someone](#patch-apiv1adminusersid)). What a member may listen to (the owner's +current access for `all`, the collections the owner shares with them for +`collections`, plus their own grants), and the one-level rule, are the member rule in +[Auth & security](../auth-and-security.md#household-members). + +**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 account [rate limit](../auth-and-security.md#rate-limiting) (`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. + +**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", + "devices": 1, + "collections": [ { "id": 4, "name": "Evie's books", "items": 2 } ], + "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 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 | +|---|---| +| `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` | + +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` + +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 (`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); if that is taken, that with the owner's username + folded the same way (`evie-sam`; skipped when nothing is left of it), then the first + part numbered (`evie-2`, `evie-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`), 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", "display_name": "Evie", "…": "…" }, "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`) | +| `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". + +| 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, 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). `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, 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}` + +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. 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, everything a kid +can't hold ([Kids](../auth-and-security.md#household-members)); 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 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`; `400` `invalid +member id`, `403` as above, `404` not the caller's member. + +### 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` (the list: `200`, `reason: "member"`) | the same | +| `/admin/**` | `403` (role `user`) | `403` (role `user`) | + +Where the `kid_account` refusals are enforced, including a request that races the +owner's switch to kid: [Kids](../auth-and-security.md#household-members). + +A kid's password sign-in fails like any password-less account's (`401` on +[`/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 *Session.* Capability `cast`. A Chromecast receiver plays a book from URLs the @@ -2827,12 +3129,45 @@ 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": "" + }, + { + "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 (`api.adminUser`): + +| Field | Meaning | +|---|---| +| `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, 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`, on the `PATCH` answer and on +the create's. + ### `POST /api/v1/admin/users` Create an account. @@ -2843,10 +3178,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}` @@ -2855,7 +3191,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, @@ -2873,10 +3209,30 @@ 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, "name": "Evie", "username": "evie", "kind": "kid", "access": "collections", + "created_at": "2026-10-13T10:00:00.000Z", "devices": 1, "disabled": false } ], + "member_collections": [] } ``` +- `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 recovery codes - recovery presence surfaces as `user.has_recovery`). @@ -2890,16 +3246,47 @@ 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); a non-empty one is refused when the request leaves the account a kid (beside Make independent, or an assign as a grown-up, it is fine) | | `disabled` | bool | reversible lockout; disabling revokes nothing but blocks all token use | - -Response `200`: the updated user object. +| `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, 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 (with a +`password` beside them when the member has none, as every member starts and a kid +always is, or it is `400 admin_needs_password`). + +- **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: 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 ([Moving between households](../auth-and-security.md#household-members)). +- **Assigning as a kid** clears what a kid can't hold, as the owner's + [switch to kid](#patch-apiv1mehouseholdmembersid) does. 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`. What it signs out is in + [Moving between households](../auth-and-security.md#household-members). 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`) | -| `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; `code: "kid_no_password"` (a non-empty `password` when the request leaves the account a kid: a kid with no household change in it, or an assign as a kid) | +| `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: "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) | + +What `user.update` records: [Audit log](../backups-and-notifications.md#audit-log). ### `DELETE /api/v1/admin/users/{id}` @@ -2908,9 +3295,21 @@ 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`, 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), as with Make +independent ([audited](../backups-and-notifications.md#audit-log) on `user.delete`). + +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 | |---|---| -| `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` | @@ -2944,6 +3343,12 @@ shows the server's numbers rather than re-deriving them. The code rides in the `invite_url` **fragment**, so it never reaches server logs; the connect page auto-redeems it client-side. +`404` if the user doesn't exist. `409 code: "kid_account"` (`not available for a kid's +account`) for a kid household member: a kid holds no invite, only their owner sets up +their devices (from the player). The refusal is read inside the minting transaction +(`auth.refuseKid`), so an owner's switch to kid can't slip between the check and the +invite. + ### `DELETE /api/v1/admin/users/{id}/recovery` *Legacy - being retired alongside `POST /auth/recovery` once old clients age @@ -2959,7 +3364,9 @@ transaction it retires the user's **other** still-redeemable invites, so the rotated invite is their one active invite - rotating an expired (or used-up) invite revives it as that one. No body. Response `200`: the same shape as creation (`auth_code`, `invite_url`, `max_uses`, `expires_at`). `404` if the -invite doesn't exist. +invite doesn't exist. `409 code: "kid_account"` when the invite's account is a kid's +(a used-up or expired invite kept as history from before the switch to kid can't be +revived into it). ### `DELETE /api/v1/admin/authcodes/{id}` @@ -5691,6 +6098,13 @@ 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, + "max_members_default": 6, + "max_members_min": 1, + "max_members_max": 50 + }, "backups": { "schedule": "daily:03:00", "keep": 7, @@ -5722,6 +6136,9 @@ 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); 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 (6) and range (1 to 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) | @@ -5758,7 +6175,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 +6676,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 +6698,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..fc146a5 100644 --- a/docs-developers/server/auth-and-security.md +++ b/docs-developers/server/auth-and-security.md @@ -46,8 +46,11 @@ codes/tokens in the response body and store only the hash. - **Session tokens** are the durable bearer credential (`Authorization: Bearer …`). Issued by `POST /auth/login` and `POST /auth/exchange` with **no - expiry**; revoked by `POST /auth/logout`, by the owner signing a device out - (`DELETE /me/devices/{id}`), or by admin action. + expiry** (and, for a [household member](#household-members), by their owner: + `sign_in_here` or `…/members/{id}/session`); revoked by `POST /auth/logout`, by + the owner signing a device out (`DELETE /me/devices/{id}`, which with + `?also_members=1` takes the members signed in on it too), by a member's owner + (`DELETE /me/household/members/{id}/devices/{device_id}`), or by admin action. - **Pairing tokens** are intermediaries minted by `POST /auth/redeem` (auth code → pairing) or `POST /auth/pair` (add another device from an existing session). `POST /auth/exchange` turns one into a device-named session token. @@ -56,9 +59,9 @@ codes/tokens in the response body and store only the hash. invite's uses and expiry - exchange claims one use per device, so one QR can pair several devices - and die with the code (delete/supersede cascade, rotate revokes); recovery-derived tokens last `pairingTTL` = 10 minutes - (multi-scan within it, since recovery codes are unlimited); `/auth/pair` and - demo tokens are unlinked - single-use (revoked on exchange) with the same - 10-minute TTL. + (multi-scan within it, since recovery codes are unlimited); `/auth/pair`, a + household member's code (`POST /me/household/members/{id}/pair`) and demo tokens + are unlinked - single-use (revoked on exchange) with the same 10-minute TTL. - **API keys** (`auth.KindAPI`) are user-minted, **non-expiring** bearer credentials for headless integrations (dashboards, cron), created / listed / revoked via `POST` / `GET` / `DELETE /auth/tokens` @@ -71,9 +74,11 @@ codes/tokens in the response body and store only the hash. `gateSelfService`, so they share the `accountLimiter` and are refused for demo accounts, exactly like the password/recovery routes. **Containment:** an API-key caller is additionally barred (403, via `denyAPIKey` in - `internal/api/handlers_auth.go`, also called by `handlers_cast.go`) from the five - credential-minting routes (`POST /auth/{tokens,recovery,pair,password}` and - `POST /cast/token`), so a leaked key can never spawn a fresh credential - another + `internal/api/handlers_auth.go`, also called by `handlers_cast.go` and + `handlers_members.go`) from the credential-minting routes + (`POST /auth/{tokens,recovery,pair,password}`, `POST /cast/token`, and every + [household member](#household-members) route, whose create, `…/session` and + `…/pair` make a member's session or pairing code), so a leaked key can never spawn a fresh credential - another key, recovery code, pairing token, or password that would outlive its own revocation, or even a [cast token](#cast-tokens), which would die with it (mirrors GitHub's "a token cannot create tokens"). It can still list/revoke keys and clear a recovery code, @@ -112,7 +117,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 @@ -254,6 +259,11 @@ 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 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 `GET /household/listening`, so a public demo never shows up in a household. It @@ -279,14 +289,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, @@ -296,18 +310,43 @@ 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: `UpdateUser` + refuses promoting one and assigning an admin as a member (`409 member_not_admin`); + "Make independent" comes first, so it passes this check in the same request as the + promotion (a member with no password also needs one in that request: + `ErrAdminNeedsPassword`). +- **`ErrKidNoPassword`** - a kid holds no password: `UpdateUser` refuses a non-empty + password when the request leaves the account a kid (a kid with no household change + in the request, or an assign as a kid), as `400 kid_no_password`. Clearing one is + fine, and so is a password beside a Make independent or an assign as a grown-up: + this one check reads the request's household change first. +- **The Assign refusals**: the account itself (`ErrSelfManaged`, checked with the kind + and access before the transaction), then inside it (`checkAssign`) an unknown owner + (`404`), 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 + but the unknown owner is `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, their open unlinked pairing tokens revoked, as + [Moving between households](#household-members) describes); a `keep` id that isn't their member + is `ErrInvalidKeepMembers` and nothing changes. The last-admin rule is checked + first. ## Authorization: shares → Scope Access control is filesystem-based. A **share** is a named set of path rules (`share_paths`: `(library_id, path)`, where `""` = the whole library); users -are granted shares, and a user's access is the union of their granted rules. +are granted shares, and a user's access is the union of their granted rules (plus, +for a household member, what their owner gives them: [the member rule](#household-members)). `catalog.GrantWholeLibrary` is sugar that ensures a `"Library: "` share with a `""` rule exists and grants it. At request time the rules are compiled into a `catalog.Scope` per library - -`AllowAll` (admins always; or any `""` rule) or a list of granted `Paths`. The +`AllowAll` (admins always; or any `""` rule; or a member on `all` whose owner is an +admin) or a list of granted `Paths`. The scope is then enforced through **three functions, each matched to a different query shape**: @@ -315,7 +354,7 @@ query shape**: |---|---|---| | `Scope.Allows(p)` | `p` is equal to or under a granted rule (segment-boundary prefix match) | `authorizedPath` in `handlers_library.go` - **every** path-addressed endpoint (`item`, `chapters`, `cover`, `stream`, progress/bookmarks/notes/history/favourites writes) | | `Scope.VisibleInBrowse(p)` | `Allows(p)` **or** `p` is an *ancestor* of a rule - so the user can navigate toward granted content | `handleBrowseFS` → passed as the `allow` filter to `library.BrowseFS` (applied before pagination so pages stay full) | -| `pathFilterSQL(col, scope)` | The same grant logic as a SQL `WHERE` fragment: `col = ?` OR the byte range `col >= 'rule/' AND col < 'rule0'` (`0` is the byte after `/`), so it is a literal, **case-sensitive** prefix with no wildcards to escape. It used `LIKE` until the player-redesign data API; `LIKE` ignores ASCII case, so a grant on `Saga` also reached a sibling `saga/...` on a case-sensitive filesystem | `ListBooks`, `Search`, `RecentBooks`, the player's browse lists (`/authors`, `/narrators`, `/series`), `/next`'s series step and the owned-book placement on `/meta` rails; `scopesFilterSQL` extends it across libraries for the cross-library listings (`/me/progress`, `/me/history`, `/me/favourites`) | +| `pathFilterSQL(col, scope)` | The same grant logic as a SQL `WHERE` fragment: `col = ?` OR the byte range `col >= 'rule/' AND col < 'rule0'` (`0` is the byte after `/`), so it is a literal, **case-sensitive** prefix with no wildcards to escape. It used `LIKE` until the player-redesign data API; `LIKE` ignores ASCII case, so a grant on `Saga` also reached a sibling `saga/...` on a case-sensitive filesystem. Over 32 paths (`jsonPathsAbove`; a `collections` [household member](#household-members) can hold a thousand) it is a single-parameter form instead (`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; a path that isn't valid UTF-8, which JSON can't carry exactly, keeps an `OR` arm of its own). `BenchmarkMemberScopes` records its cost | `ListBooks`, `Search`, `RecentBooks`, the player's browse lists (`/authors`, `/narrators`, `/series`), `/next`'s series step and the owned-book placement on `/meta` rails; `scopesFilterSQL` extends it across libraries for the cross-library listings (`/me/progress`, `/me/history`, `/me/favourites`) | The Go predicate (`pathAllowedBy`) is authoritative; `pathFilterSQL` must stay behaviorally identical to it. @@ -363,12 +402,14 @@ its owner. They follow a few rules on top of share scope: sees the collection's name, description and the owner's username, and only the items **their own** current access allows: the owner's other items, and how many there are, are never revealed (`item_count` and `preview` are the - viewer's), and sharing grants no access to any book. Only the owner changes + viewer's), and sharing grants no access to any book, except to the owner's own + household member on `collections` access, whose access is the books of the + collections their owner shares with them ([the member rule](#household-members)). Only the owner changes the collection, its items or its shares; a viewer can only leave it. The owner's `shared_with` list is never sent to a viewer. - **The share picker shows usernames, so demo accounts are kept out.** `GET /me/share-targets` lists every enabled, non-demo account other than the - caller (id and username): the one way a non-admin can list every account's + caller (id and username; a kid household member only to their own owner): the one way a non-admin can list every account's username (a shared collection also shows its owner's, and [listening in the house](#listening-in-the-house-what-other-people-see) the usernames of the other people who share). A demo account is refused it (`403 not available for demo @@ -436,6 +477,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** @@ -448,6 +493,131 @@ 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 + +Household members (capability `household_members`) let 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`) that the owner sets (and the admin, with +Assign to someone). 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. A demo account is never either (`ErrOwnerIsDemo`, +`ErrMemberIsDemo`). + +**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 + 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 scope in that library + allows it (`Scope.Allows`). 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 whose path is empty or not canonical (`relpath.Clean(p) != p`) grants + nothing, so none 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. + +**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's scope can hold a thousand item paths; +`pathFilterSQL` binds them as one parameter past 32 (see +[the Scope table](#authorization-shares--scope)). + +**Creating accounts without an admin** (`auth/members.go`, `CreateMember`, one +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 +account [rate limit](#rate-limiting). + +**Kids.** A kid account holds no password, API key, recovery code, invite or house +sharing, can't make a pairing code of its own (`/auth/pair`), and shares collections only with +its owner (**Visibility**, below). `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 share something refuse a kid again inside their +own transaction (`auth.refuseKid` in `IssueAPIToken`, `GenerateRecoveryCode` and +`IssueOwnPairingToken`; `catalog.SetHouseholdSharing` and +`catalog.SetCollectionShares`), 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`). Each answers `403 kid_account`, +as the wrapper does. +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`). +The admin can't mint or rotate a kid's invite: `POST /admin/users/{id}/authcode` and +`POST /admin/authcodes/{id}/rotate` answer `409 kid_account` (`auth.refuseKid` inside +`CreateInvite` and `RotateAuthCode`, so a used-up invite kept as history can't be +revived into a kid's account), and the console offers neither on a kid's page. +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` +(`kidOnlyToOwnerSQL`; the same answer as an unknown id, so it can't be used to probe +who is a kid). Listening in the house leaves kids out both ways (see +[Never a kid](#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`. 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` Any filesystem access derived from user input goes through @@ -477,7 +647,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 @@ -577,7 +747,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..bc99e75 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` when the request sets it, and `managed_by` (the owner's username, or `null`), `member_kind` and `member_access`, as the account is afterwards, whenever the request carries `managed_by`), `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 (an admin too can be one), 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..9c445ab 100644 --- a/docs-developers/server/configuration.md +++ b/docs-developers/server/configuration.md @@ -163,6 +163,23 @@ 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 (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` | Any account but a demo account or a household member 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. 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.*`) The server can enrich a book with a description, production details and its @@ -404,6 +421,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 +482,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 +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 (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 4311e47..f87b648 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,24 @@ 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, kept when they stop being one (Make independent, `keep_members`); +`''` for anyone never given one, a member the admin assigned included until the owner +renames them). 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 +(`memberScan`). `SET NULL` is only a backstop: `auth.DeleteUser` clears all three +member columns of each member an admin keeps and deletes the others explicitly, in the +same transaction and before the owner's row, so no row still points at the owner when +it goes. 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 +168,15 @@ 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 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`). @@ -1006,6 +1031,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-developers/server/web-ui.md b/docs-developers/server/web-ui.md index aa35202..89831ec 100644 --- a/docs-developers/server/web-ui.md +++ b/docs-developers/server/web-ui.md @@ -609,8 +609,8 @@ CSP-sensitive work against a real build served by Go. The console's own gate is - **Server > Settings** (`features/settings/`) - over [`GET`/`PATCH /admin/settings`](api/reference.md#admin-settings): an in-page topic list in `?topic=` (`general` is the default and has no param; - `network`, `players`, `metadata`, `transcoding`, `demo`, `backups`, - `notifications`; `settings-model.ts` `SETTINGS_PAGES`). Each card is a `SettingsForm` + `network`, `players`, `metadata`, `transcoding`, `household`, `demo`, `backups`, + `notifications`, `import`; `settings-model.ts` `SETTINGS_PAGES`). Each card is a `SettingsForm` (`settings-form.tsx`): a draft of its fields, Reset and Save changes, only the changed fields sent (`sectionPatch`); list settings are edited as one entry per line. A refusal's `field` puts the server's message under that 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/console-tour.md b/docs-users/admin/console-tour.md index c3353c1..c2b25be 100644 --- a/docs-users/admin/console-tour.md +++ b/docs-users/admin/console-tour.md @@ -102,7 +102,7 @@ What each section is for: | **Health > Issues** | Books that could be better (unreadable files, missing covers, likely duplicates and more), each with a fix or Ignore, and offline libraries. See [Library health](health.md#library-health). | | **Health > Jobs** | The scan running now, the scans waiting, the schedules, and every scan's history and log. See [Jobs](health.md#jobs). | | **Health > System** | Everything the server depends on (ffmpeg, community metadata, the certificate, the database, backups, each library's disk, the version), each with a status. See [System](health.md#system). | -| **Server > Settings** | The server's settings by topic: name and address, network and HTTPS, players and app links, community metadata, transcoding, demo mode, [backups](backups.md), [notifications](notifications.md) and [importing from Audiobookshelf](import.md). See [Settings](server.md#settings). | +| **Server > Settings** | The server's settings by topic: name and address, network and HTTPS, players and app links, community metadata, transcoding, household members, demo mode, [backups](backups.md), [notifications](notifications.md) and [importing from Audiobookshelf](import.md). See [Settings](server.md#settings). | | **Server > Logs** | What the server has been doing since it started, with a live tail. See [Logs](server.md#logs). | | **Server > Audit log** | What admins changed, and when, kept for a year. See [Audit log](server.md#audit-log). | | **Server > About** | The version, whether a newer one exists and how to update, and facts about the server. See [About and updates](server.md#about-and-updates). | @@ -192,7 +192,7 @@ use the arrow keys to move and Enter to open. - **Go to** - every destination, and (once you type) every section. - **Settings** - each [Server > Settings](server.md#settings) topic (**General**, **Network & HTTPS**, **Players & app links**, **Community - metadata**, **Transcoding**, **Demo mode**, **Backups**, + metadata**, **Transcoding**, **Household**, **Demo mode**, **Backups**, **Notifications** and **Import**; typing "https", "proxy", "ffmpeg", "restore", "webhook" or "audiobookshelf" finds the right one), **Scan schedules and skipped files** (opens Library > Libraries, where each library's @@ -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..47f97c6 100644 --- a/docs-users/admin/server.md +++ b/docs-users/admin/server.md @@ -1,6 +1,6 @@ --- title: "Server settings, updates and logs" -description: "The Server pages of the AudioSilo admin console: every setting you can change while the server runs (name, address, HTTPS, app links, community metadata and keeping a local copy of it, transcoding, demo mode, backups, notifications, importing from Audiobookshelf), which ones wait for a restart, checking for new versions and what that sends, reading the server's log, and the audit log of what admins changed." +description: "The Server pages of the AudioSilo admin console: every setting you can change while the server runs (name, address, HTTPS, app links, community metadata and keeping a local copy of it, transcoding, household members, demo mode, backups, notifications, importing from Audiobookshelf), which ones wait for a restart, checking for new versions and what that sends, reading the server's log, and the audit log of what admins (and the people managing household members) changed." --- The **Server** destination in the [admin console](console-tour.md) is where @@ -8,7 +8,8 @@ you look after the server itself: - **Settings** - the server's settings, one topic at a time. - **Logs** - what the server has been doing since it started. -- **Audit log** - what admins changed, and when. +- **Audit log** - what admins changed (and what people did with their household + members), and when. - **About** - which version you run, whether a newer one exists, and how to update. @@ -21,8 +22,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 +314,24 @@ 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 @@ -419,7 +438,8 @@ A few things to know: ![Server > Audit log](/img/screenshots/admin/audit.png) -**Server > Audit log** lists what admins changed, newest first: when, who, +**Server > Audit log** lists what admins changed, and what people did with their +household members in the player, newest first: when, who, what they did and to what, with the details underneath. For example "Changed settings" with each setting's old and new value, "Edited a book" with the fields that changed, "Gave access" with the person and the share, or @@ -429,7 +449,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 (what the people who manage them do in the +player, naming them 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,17 +464,18 @@ 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. 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), -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** +To narrow the list, choose an **Area** (Accounts, Household members, Invites, +Devices, Shares and access, Progress, Imports, Libraries, Books, Health, Settings, +Backups, Notifications), a person under **Who** (everyone but demo accounts and +household members, since the people who manage household members appear in the log +too), 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. ## Where to next diff --git a/docs-users/admin/sharing.md b/docs-users/admin/sharing.md index 5984ab8..f85f56f 100644 --- a/docs-users/admin/sharing.md +++ b/docs-users/admin/sharing.md @@ -14,12 +14,15 @@ where you build them. The rules are short: -- **A new person sees nothing until you give them something.** There is no +- **A person you invite sees nothing until you give them something.** There is no default library. The [Invite someone](users-and-invites.md#inviting-someone) dialog asks what they can listen to, so nobody has to start empty-handed. - **Admins see everything.** Shares never restrict an admin account. - **Access is the union of a person's shares.** Give someone two shares and - they see everything either one covers. + they see everything either one covers. A + [household member](users-and-invites.md#household-members) also gets what + their owner gives them: everything the owner can listen to, or the + collections the owner shares with them. - All access over the network is **read-only** - a share lets someone listen, never change or delete your files. diff --git a/docs-users/admin/users-and-invites.md b/docs-users/admin/users-and-invites.md index 489f1cc..42b3c89 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, @@ -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: @@ -18,6 +18,10 @@ 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), **Managed by** *owner* and + **Kid** or **Grown-up**, after the name their owner gave them when it differs + from the username by more than capital letters; 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 +33,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). +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 @@ -124,6 +131,16 @@ 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. + +![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. @@ -148,7 +165,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. @@ -162,7 +182,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 +195,92 @@ 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 or a household member, 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 no invites. They sign in only on devices their + owner sets up, so a kid's page offers no **Pair a device** and no **Rotate** + (what else a kid can't do: + [Adding a kid](../listening/household.md#adding-a-kid)). + A **grown-up** member is an ordinary account otherwise. +- A member never has members of their own. +- 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 under the +username only when it differs by more than capital letters (*Evie* under +*evie-adam*, but not under *evie*). + +### The two settings and the per-person switch + +- **Server > Settings > Household** turns adding household members on or off + for the whole server (on out of the box; off stops new members, and members + already added keep working) 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, with the same effect. + +### 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: to add a device, + [pair one](#a-persons-page) for them or set a password. +- **Assign to someone** - moves them to another person's household. + +Either way, the devices they set up themselves stay signed in, and any unused +code made in the player for one of their devices (by their owner, or by them) +stops working. The devices someone else signed them in on, such as their owner's +phone, stay signed in when they're made independent. When they're assigned, those +devices are signed out, except the ones belonging to the person they're assigned +to. + +**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, and their own grants stay. + +- Making someone a kid takes away everything a kid can't have (see + [A member's card](../listening/household.md#a-members-card)); their + signed-in devices stay signed in, and no password can be set for them while + they're a kid. +- Choosing **Only collections Adam shares** (the option names the person you + pick) means they get books through Adam only from the collections Adam 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 them under **Their household member** +(or **Their 2 household members**), each ticked (**Delete Evie too**). Untick a member to keep them as an independent +account, as with **Make independent**. 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 +350,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 +405,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/faq.md b/docs-users/faq.md index 9b8d5bf..ad04e29 100644 --- a/docs-users/faq.md +++ b/docs-users/faq.md @@ -161,13 +161,19 @@ different: they can already see everyone's listening in the admin console's There's no fixed user limit - create an account for everyone in the house. Each user gets their own listening progress, bookmarks, notes, and -favourites, and joins by redeeming an invite code (no password needed for -listeners - see [People and invites](./admin/users-and-invites.md)). +favourites. You invite people with an invite code (no password needed for +listeners - see [People and invites](./admin/users-and-invites.md)), and, +unless you turn it off, people can also add the kids and grown-ups they live +with from the player, with no invite (see +[Your household](./listening/household.md)). Access is controlled with **shares**: named sets of folders (one author, one series, a single book, or a whole library) that you grant to users. A user sees only what they've been granted - browsing, search, and playback are all -scoped to match. See [Sharing](./admin/sharing.md). +scoped to match. A household member also gets what the person who added them +chooses: everything that person can listen to, or only the collections they +share. See [Sharing](./admin/sharing.md) and +[Household members](./admin/users-and-invites.md#household-members). ## The desktop manager & Audible diff --git a/docs-users/getting-started/remote-access.md b/docs-users/getting-started/remote-access.md index 9a9381f..1e81227 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). Someone who already has an account can also add the kids and grown-ups they live with as [household members](../listening/household.md), signed in by them and never through a public page, within limits [the admin sets](../admin/users-and-invites.md#household-members). - **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..940e381 100644 --- a/docs-users/listening/account.md +++ b/docs-users/listening/account.md @@ -26,6 +26,17 @@ 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. +- There's no **Pair another device** card: the grown-up who manages the account sets up its devices, with **Set up their own device** on their [Household page](household.md#their-devices). +- 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."* + ## Pair another device Sign another phone, tablet or browser in to the same account without a new invite: @@ -48,6 +59,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, as for a family tablet that stays at home. Their sign-ins on other devices, including their own phones, aren't touched. + **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. @@ -62,6 +75,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."*, with no **Set a password** button, since a kid's account can't have one. + 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/api-keys.md b/docs-users/listening/api-keys.md index b3099f4..831dc21 100644 --- a/docs-users/listening/api-keys.md +++ b/docs-users/listening/api-keys.md @@ -5,14 +5,14 @@ description: "Create a personal API key so a dashboard, script, or monitoring to An **API key** is a long, secret token you create for yourself so another app can talk to your AudioSilo server *as you*, without handing over your password. It is the right tool when you want a home dashboard (such as Heimdall), a script, a cron job, or an uptime monitor to reach your server automatically. -Each key acts as you for everyday requests - browsing your libraries, reading your books, saving your listening progress. You can create as many as you like, name each one, and revoke any of them at any time. (A key can't manage your account itself - it can't create another key or set your password - and it can't start casting to a Chromecast. Those always need you signed in normally, so a leaked key can't lock you out or quietly multiply itself.) +Each key acts as you for everyday requests - browsing your libraries, reading your books, saving your listening progress. You can create as many as you like, name each one, and revoke any of them at any time. (A key can't manage your account itself - it can't create another key, set your password or manage your [household members](household.md#household-members) - and it can't start casting to a Chromecast. Those always need you signed in normally, so a leaked key can't lock you out or quietly multiply itself.) ## Where to find it In the player, open your [account page](account.md#opening-it) for the server. There you'll find a **Personal API keys** section. :::note -On a server too old to offer API keys, the section says it isn't available there. It is hidden for **demo accounts** - a throwaway demo login can't create keys. +On a server too old to offer API keys, the section says it isn't available there. It is hidden for **demo accounts** (a throwaway demo login can't create keys) and for a [kid's account](account.md#a-kids-account) from someone's household, which can't have keys. ::: ## Creating a key diff --git a/docs-users/listening/browsing.md b/docs-users/listening/browsing.md index aae20a5..454af72 100644 --- a/docs-users/listening/browsing.md +++ b/docs-users/listening/browsing.md @@ -15,7 +15,7 @@ AudioSilo arranges itself to fit the screen it's on. - **Library** - everything on your server, by book, author, series, narrator, collection or folder. - **Search** - find books, series, authors, narrators, and characters you've already met. - **Downloads** - books saved on this device for [offline listening](offline-downloads.md). -- **Me** - [You](you.md): your listening stats, your [Year in listening](year-in-listening.md), your [Journal](journal.md), your [Settings](settings.md) and your [account](account.md). +- **Me** - [You](you.md): your listening stats, your [Year in listening](year-in-listening.md), your [Journal](journal.md), your [Settings](settings.md), your [household](household.md) and your [account](account.md). Each tab remembers where you were. Open a book from Search, switch to Library for a moment, come back to Search, and the book is still open there; going back takes you to your search results. Tap the tab you're already on to jump back to its first page. @@ -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 (profiles)](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) @@ -225,7 +225,7 @@ Start typing and the results are grouped: - **Actions** - things you can do right now. While a book is loaded: **Pause** (or **Resume** the current chapter), **Sleep in 30 minutes**, **Sleep at end of chapter** (for books with chapters) and **Open the full player**. On a server with [Up next](up-next.md): **Open Up next**, with how many books are queued. Always: a switch to **light or dark** appearance. Setting a sleep timer this way shows a short confirmation at the bottom of the screen. - **Books** - matching titles, authors and series from every server you're connected to, the same search as the Search page. Before you type anything, this group is **Continue listening** instead: the books you're partway through, with how much you've listened to. - **Series**, **Authors**, **Narrators** and **Characters** - up to three of each, from the same search as the Search page. Characters follow the same rule: only ones you've met, with a quiet note counting any you haven't. -- **Go to** - Home, Library and Downloads, then [Your listening](you.md#your-listening), [Year in listening](year-in-listening.md), your [Journal](journal.md) and [Settings](settings.md). +- **Go to** - Home, Library and Downloads, then [Your listening](you.md#your-listening), [Year in listening](year-in-listening.md), your [Journal](journal.md), [Settings](settings.md) and [Household](household.md). Use the **arrow keys** to move through the results, **Enter** to open one, and **Esc** to close. Book results say which server and library each one comes from, as on the Search page. Your recent searches appear as **Recent** shortcuts the next time you open it; it's the same list as the Search page's, kept on this device only. diff --git a/docs-users/listening/connecting.md b/docs-users/listening/connecting.md index bb5f752..e4255eb 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 from an invite keeps working as long as the invite does, for as many devices as it has uses left; one made on another device (or a household member's code) works once, for 10 minutes. One that has expired or been used up 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 @@ -73,7 +75,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..14b4e2d --- /dev/null +++ b/docs-users/listening/household.md @@ -0,0 +1,124 @@ +--- +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 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, 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 (profiles)](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**. When more than one of your servers offers it, each 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. + +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 off adding household members for the whole server. +- *"Your server's admin hasn't let this account add people."*: the admin turned it off for your account. +- *"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."*, using their name on Who's listening? when they're signed in here too, else their name on the server. + +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 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**. + +![Adding a kid: the name typed, Kids account on, Only collections I share with an existing collection left unticked and the new Mia's books ticked, and Add and sign in here](/img/screenshots/web-player/household-add-kid.png) + +The new collection is only made when you tap **Add and sign in here**, so **Cancel** leaves nothing behind. + +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). The server's admin can't pair one for it. + +## 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 Ruth 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 Ruth 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. + +## 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. Either choice works for either kind of person. + +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](kids-mode.md#what-the-child-sees) shows the collections you share with them, or, while you share none, one library. + +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."* + +## 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 (*"Kid · evie"*), and **On this device** when they're signed in here. On 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, once you confirm (*"Make Ruth a kids account?"*). Everything a kid can't have goes: their password, API keys, unused pairing codes and invites, recovery code, sharing with the house, and any collection sharing with anyone but you. The devices they're signed in on stay signed in. Switch it off to make a kid a grown-up, who can then set a password and turn sharing with the house back on. When they're signed in on this device too, their account here follows straight away. +- **Their devices** (below). +- **Name**, with **Rename**: the name you gave them, shown on your Household page and to the server's admin, and, when they're signed in here, their name on this device too. Their username stays the same, and it's the name other people on the server see, such as in Listening in the house. +- **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, the app says why, and nothing changes. + +## Their devices + +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 (for this device, see [When a member is signed out](#when-a-member-is-signed-out-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, 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. **Sign in on this device** says *"Evie's account is turned off on this server. Only its admin can turn it back on."*, and **Set up their own device** says *"This account is turned off on Hearthside. Only the server's 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. + +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 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 (profiles)](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 + +Kids and grown-ups you signed in **on your own phone** go with it: signing the phone out from your [account page](account.md#signed-in-devices) signs them out there too, unless you untick them. Untick them for a family tablet that stays at home, so only you are signed out. 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 off adding household members for everyone or for one person (the people already added keep working), 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..9fda70b 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 (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 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..fd4050a 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."* @@ -25,7 +25,7 @@ The switch is per server: if you're signed in to two servers, you choose for eac - the book's title and cover; - when, and the chapter: **Listening now**, **Paused**, **Earlier today**, **Yesterday** or **Recently**, then the chapter they're on (*"Listening now · Chapter 4"*). -Tap a card to open that book's page. **Sharing settings** beside the title takes you to the switch. When nobody else is listening, the section isn't shown at all. +Tap a card to open that book's page. **Sharing settings** beside the title opens the [Household page](household.md), where the switch is in its last part, **Listening in the house**. When nobody else is listening, the section isn't shown at all. ![Listening in the house on the Home page: jamie listening now to The Hound of the Baskervilles and rosa paused on The Call of the Wild, with Sharing settings beside the title](/img/screenshots/web-player/home-house.png) @@ -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..be4373d 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. +You choose what they can hear (see [What they can listen to](household.md#what-they-can-listen-to)): kids mode shows the collections you share with them as their stories, or, with none, one library (the **Library** below). + +### 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. 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. + +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. @@ -27,14 +41,22 @@ 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. +- **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) -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**. +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 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 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. When kids mode shows one library, they come from that library when the app knows which one it 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, and so it does for a kid in your household whose stories are the collections you share (a collection can hold books from several libraries). With no downloads, it says the stories can't load and offers **Try again**. + +### 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 whose sign-in was ended, that signs them straight back in, with no password, keeping the child's profile and kids mode. After the server was reset, it opens the usual sign-in for that server instead. + +### 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 @@ -47,6 +69,12 @@ 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. + +## 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. -To stop using kids mode for good, turn **Kids mode** off for that person in Settings > Household and sharing. +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. diff --git a/docs-users/listening/mobile-apps.md b/docs-users/listening/mobile-apps.md index eb537c5..e20922c 100644 --- a/docs-users/listening/mobile-apps.md +++ b/docs-users/listening/mobile-apps.md @@ -31,7 +31,7 @@ Signing the app in is usually a scan, not typing - all the routes are covered in ## Getting around the app -On a phone, the app uses your phone's own tab bar along the bottom of the screen, with five tabs: **Home**, **Library**, **Search**, **Downloads** and **Me** ([You](you.md): your listening, your year, the Journal, Settings and your account). Each tab keeps its place while you visit the others. See [Getting around](browsing.md#getting-around) for what each one holds. +On a phone, the app uses your phone's own tab bar along the bottom of the screen, with five tabs: **Home**, **Library**, **Search**, **Downloads** and **Me** ([You](you.md): your listening, your year, the Journal, Settings, your household and your account). Each tab keeps its place while you visit the others. See [Getting around](browsing.md#getting-around) for what each one holds. - **iPhone**: the standard iOS tab bar. On **iOS 26 or later** it's the translucent "Liquid Glass" bar, with **Search** set apart as its own round button; the bar shrinks as you scroll down, and the [mini player](playback.md#the-mini-player-and-the-player-bar) sits inside it, just above the tabs. On older iOS versions the mini player floats just above the bar instead. - **Android**: the standard Material navigation bar, with every tab labelled and the current one highlighted by a soft pink pill. The mini player floats just above it. diff --git a/docs-users/listening/profiles.md b/docs-users/listening/profiles.md index ea06a08..3f42062 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." +title: Sharing a device (profiles) +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 the bedtime, Add someone, and Manage household at the foot](/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,30 @@ 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) +![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) -[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. +- **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. -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..319f8b8 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. @@ -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) ignores it: a story the child hasn't played yet 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**) | @@ -85,23 +85,13 @@ 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. ## Accounts and devices -The servers you're signed in to on this device (with more than one person on the device, only the person listening's: see [Sharing a device with your household](profiles.md)), each with your username and its address. Tap one to open your [account on that server](account.md): your password, pairing another device, your signed-in devices, API keys and signing out. **Add a server** connects another one - your home screen, search, and favourites then combine everything (see [Connecting and signing in](connecting.md)). The web player doesn't offer it, since it only reaches the server it's served from. The bin button beside a server removes your sign-in to it from this device. +The servers you're signed in to on this device (with more than one person on the device, only the person listening's: see [Sharing a device (profiles)](profiles.md)), each with your username and its address. Tap one to open your [account on that server](account.md): your password, pairing another device, your signed-in devices, API keys and signing out. **Add a server** connects another one - your home screen, search, and favourites then combine everything (see [Connecting and signing in](connecting.md)). The web player doesn't offer it, since it only reaches the server it's served from. The bin button beside a server removes your sign-in to it from this device. :::warning Removing a server also **deletes the books you downloaded from it on this device**, plus any of your listening progress that hasn't synced back yet. The app asks first if you have downloads from it on the device. Your other servers, and anyone else on this device signed in to the same server, are unaffected, and nothing on the server itself is touched. See [Offline downloads](offline-downloads.md). @@ -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). You can make accounts for the kids and grown-ups you live with 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/screenshots/README.md b/screenshots/README.md index 4a97911..bc828f0 100644 --- a/screenshots/README.md +++ b/screenshots/README.md @@ -97,7 +97,12 @@ 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, after the listening is set up and only when +those two shots are wanted: ivy, a kid on "Ivy's books", one of maya's collections, +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 (without the household +both keep their previous images); 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 +169,62 @@ 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 +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 rosa's last save as a minute +old; rosa is saved again if more than 5 minutes passed before Home is shot). Then, as +Ellen, through the player's own API (`addHousehold` in `lib.mjs`, which `capture-admin.mjs` +uses for maya's household too), it makes a "Leo's stories" collection (Alice, +Looking-Glass, A Christmas Carol) and **Leo**, a kid on "Only collections I share" with +that collection, only when a shot that shows Leo is wanted (the collection also for the +add-a-kid shot). **Ruth**, a grown-up on "Everything I can listen to", is added with the +add dialog when `household-add-grownup.png` is wanted, else through the API when +`household.png` is. + +Then it drives the UI as a family would, in a fresh browser: Ellen signs in by +password, the Household page renames ellen to Ellen, "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 Leo's member card (Leo joins the device in kids +mode, as a kid's account does; the toast that says so is dismissed) and turns sharing +on. The On this device shot is taken at 1440x1000 with Leo's card at the top, and the +member card shot clips Leo's card (marked On this device, with this browser as This +device). The add-a-grown-up shot adds Ruth with Kids account off and waits for the +code's countdown; it runs before the Household page shot, which opens the same storage +in a light context (the app's own theme pick set to light), 1440 wide and as tall as +Your household and what follows, scrolled to Your household with Leo's and Ruth's +cards (without both cards it keeps its previous image). The add-a-kid shot types Mia +and picks "Create Mia's books" (then Cancel: nothing is saved). Leo's shots choose Leo +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 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. +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 (deleting Ellen deletes +Ellen's members too), 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). Accounts left over from a run that was killed are deleted before +provisioning: the owners (their members go with them) and any member whose owner isn't +one of them. A run that only wants `player-cast.png` (`SHOTS_ONLY`) provisions Ellen +alone. + +**The self-service rate limit.** Adding a household member, a member's session and +pairing code, and API keys (making one, and listing them, as an Account page does) +share the server's self-service limit: 10 per address, reset after 15 minutes with no +spend. A full run spends 8 of them (the web run 5: Leo, Leo's session here, Ruth, +Ruth's code and the admin's Account page for `account.png`; the admin run 3: ivy, dan +and theo's API key), and each `run.sh` starts a fresh server, so the budget starts full. Only a hand-run +`node capture-web.mjs` against a server that is still running needs 15 quiet minutes +first. 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..08dac06 100644 --- a/screenshots/capture-admin.mjs +++ b/screenshots/capture-admin.mjs @@ -38,7 +38,21 @@ import http from 'node:http'; import {cp, mkdir, rm, writeFile} from 'node:fs/promises'; import path from 'node:path'; import {chromium} from 'playwright'; -import {CACHE, DAY, MIN, apiClient, isoAgo, pathQuery, sleep, shoot, step, wanted, DESKTOP_CONTEXT} from './lib.mjs'; +import { + CACHE, + DAY, + MIN, + addHousehold, + apiClient, + around, + isoAgo, + pathQuery, + sleep, + shoot, + step, + wanted, + DESKTOP_CONTEXT, +} from './lib.mjs'; const ORIGIN = (process.env.AS_ORIGIN || 'http://127.0.0.1:8790').replace(/\/$/, ''); const ADMIN = `${ORIGIN}/admin`; @@ -262,6 +276,15 @@ const LISTENERS = [ ]; let mayaId = null; let heartbeats = null; +// Maya's household (see README.md): a kid on a collection maya shares with them and a +// grown-up on everything maya 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(); @@ -345,7 +368,25 @@ 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)'); + + // Maya's household last and on its own, only for the shots that show it (each member + // spends the server's self-service rate limit): a server without household members, + // or a refusal, costs those two shots (people.png, person-member.png) their new + // images, never the listening above. + if (wanted('admin/people.png', 'admin/person-member.png')) { + try { + // A collection of maya's for Ivy, then the two members. + const household = await addHousehold(api, LISTENERS[0].token, library.id, { + collection: MEMBER_COLLECTION.name, + items: books.slice(0, MEMBER_COLLECTION.books).map((b) => b.rel_path), + members: MEMBERS, + }); + memberId = household.members.find((m) => m.kind === 'kid')?.id ?? null; + } catch (e) { + console.log(` ! maya's household not provisioned: ${e.message}`); + } + } + console.log(` ✓ provisioned (maya, theo and nora listening${memberId ? "; maya's household" : ''})`); }); // ── Capture ──────────────────────────────────────────────────────────────── @@ -520,6 +561,8 @@ await step('folder detection', async () => { }); await step('people', async () => { + // People shows maya's household as a block: without it the shot keeps its previous image. + if (wanted('admin/people.png') && !memberId) throw new Error("maya's household was not provisioned"); await open(page, '/people'); await shoot(page, 'admin/people.png'); }); @@ -532,6 +575,13 @@ await step('a person (Access tab)', async () => { await shoot(page, 'admin/person.png'); }); +await step("a household member's page (Access tab)", async () => { + if (!wanted('admin/person-member.png')) return; + 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}); @@ -979,14 +1029,6 @@ const sqlite = (sql) => { if (r.status !== 0) throw new Error(`sqlite3: ${(r.stderr || r.error?.message || '').trim()}`); return r.stdout; }; -// A clip around `boxes` (each from boundingBox()), `pad` CSS pixels wider on every side. -const around = (boxes, pad = 16) => { - const x = Math.max(0, Math.min(...boxes.map((b) => b.x)) - pad); - const y = Math.max(0, Math.min(...boxes.map((b) => b.y)) - pad); - const right = Math.max(...boxes.map((b) => b.x + b.width)) + pad; - const bottom = Math.max(...boxes.map((b) => b.y + b.height)) + pad; - return {x, y, width: right - x, height: bottom - y}; -}; await step('support card', async () => { sqlite(`UPDATE users SET created_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-40 days') WHERE is_demo = 0;`); diff --git a/screenshots/capture-web.mjs b/screenshots/capture-web.mjs index 5edce4e..42de15e 100644 --- a/screenshots/capture-web.mjs +++ b/screenshots/capture-web.mjs @@ -9,9 +9,12 @@ // 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 -// shots and, through a local https proxy, the Cast shot, then deleted again. +// 3. the household: three accounts made through the admin API (ADMIN_PASSWORD), and, +// as one of them, a collection and the kid household member through the player's +// own API (the grown-up member through the add dialog, or the API when only the +// Household page shot wants one), 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'; import http from 'node:http'; @@ -25,7 +28,9 @@ import { DESKTOP_CONTEXT, HOUR, MIN, + addHousehold, apiClient, + around, firstVisible, isoAgo, pathQuery, @@ -965,36 +970,39 @@ 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 ─── +// These 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 live in this browser's +// localStorage, set up by driving the UI as a family would. Who is made, how, and what +// each shot drives: README.md (the household shots). provisionHouse makes the accounts, +// captureHouse drives the UI, unprovisionHouse removes them and their traces again. 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 Ellen's household members. +// Leo (`here`: signed in on Ellen's device from Leo's member card) comes from the API. Ruth +// is added with the add dialog when its code-step shot is wanted, else from the API. +const KIDS_COLLECTION = {name: "Leo's stories", books: ["Alice's Adventures", 'Looking-Glass', 'Christmas Carol']}; +const KID = {name: 'Leo', username: 'leo', kind: 'kid', access: 'collections', here: true}; +const GROWNUP = {name: 'Ruth', username: 'ruth', kind: 'grownup', access: 'all'}; +// The add-a-kid shot's name, typed and never saved. +const ADD_KID_NAME = 'Mia'; const KIDS_STORY = 'Alice'; -const KIDS_USERNAME = HOUSE_DEVICE.find((p) => p.kids).username; // 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, KID, 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 runs from before household members made for Leo's account, removed if one was +// left. A migration crutch: remove it (and its cleanup in provisionHouse) once no +// hand-run server from before household members is about (run.sh starts each one empty). +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,14 +1010,21 @@ 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', phoneKids: 'web-player/phone-kids.png', cast: 'web-player/player-cast.png', }; +// The shots that show Leo: Leo (and Leo's collection) are made only when one is wanted. +const KID_SHOTS = ['device', 'member', 'page', 'profiles', 'kids', 'phoneKids'].map((k) => HOUSE_SHOTS[k]); // Kids mode's bedtime card reads the clock: every household context runs in a fixed-offset // zone where it is 8 pm now (after Leo's 7:30 pm bedtime), so the card says "It's bedtime" @@ -1021,17 +1036,23 @@ 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 Ellen's 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); + // Deleting an owner deletes their members too: a member goes by itself only when its + // owner isn't one of these. + const staleIds = new Set(stale.map((u) => u.id)); + for (const u of stale.filter((x) => !staleIds.has(x.managed_by?.id))) { + 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 +1064,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 +1081,33 @@ 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 Ellen's Home has a Now + // card; then, as Ellen through the player's API, Leo's collection and Ellen's 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}); + // Each member spends the server's self-service rate limit: only the ones a wanted + // shot shows. The add-a-kid shot lists Leo's collection too. + const wantKid = wanted(...KID_SHOTS); + const members = [ + ...(wantKid ? [KID] : []), + ...(wanted(HOUSE_SHOTS.page) && !wanted(HOUSE_SHOTS.addGrownUp) ? [GROWNUP] : []), + ]; + const collection = wantKid || wanted(HOUSE_SHOTS.addKid) ? KIDS_COLLECTION.name : undefined; + if (!viewerOnly && (collection || members.length)) { + const made = await addHousehold(api, token, library.id, { + collection, + items: KIDS_COLLECTION.books.map((t) => find(t).rel_path), + members, + }); + house.members.push(...made.members); + } + } finally { + await api(token, 'POST', '/auth/logout').catch(() => {}); + } } if (viewerOnly) { house.ready = true; @@ -1106,7 +1139,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(', ') || 'none yet'}`, + ); } // Wait until the server reports housemate `username` as `state` (asked as another @@ -1126,32 +1162,43 @@ async function houseState(house, username, state, timeoutMs = 2 * MIN) { async function unprovisionHouse(house) { if (house.beat) clearInterval(house.beat); + // Deleting Ellen deletes Ellen's members too. 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 Ellen's 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. -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}); +// The Household page, by its route (`you?section=household` on a phone). A reload is a +// launch, and with two people on the device it asks "Who's listening?" first: close it. +async function openHousehold(page, route = 'household') { + await page.goto(BASE + route, {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); + await page.mouse.move(0, 0); } -// A person's card in the Household pane, by the name it shows. +// The Household page, opened again only when the browser isn't on it or a dialog covers +// it (a reload is a launch, and a launch asks "Who's listening?"). +async function ensureHousehold(page) { + const onIt = (await tid(page, 'household-page').count()) > 0; + const dialog = (await firstVisible(page.getByRole('dialog')).count()) > 0; + if (!onIt || dialog) await openHousehold(page); +} + +// 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 +1206,24 @@ 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}); } +// Shoot one element (a card) with a margin round it, after scrolling it into view. The +// default margin stays under the 12 px between the Household page's cards (gap-3), so no +// neighbour shows at the edges. +async function shootElement(page, locator, file, margin = 8) { + 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: around([box], 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 +1257,35 @@ 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'); 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 Leo's member card. let peopleReady = false; await step('household: people', async () => { await page.goto(BASE + 'connect', {waitUntil: 'networkidle', timeout: 45000}); @@ -1216,14 +1295,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 +1313,157 @@ 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.") is dismissed 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 + .locator('xpath=ancestor::div[.//*[@aria-label="Dismiss"]][1]') + .getByRole('button', {name: 'Dismiss', exact: true}) + .click({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) && kid) { + // 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 scrollToTop(personCard(page, kid.name)); await page.mouse.move(0, 0); - await shoot(page, HOUSE_SHOTS.household); + 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.addGrownUp)) { + // Ruth added (Kids account off: Everything I can listen to), at the code step: "Show + // Ruth this code", the QR and "They'll also use this device". Before the page shot, + // which shows Ruth's card. + await step('household: add a grown-up', async () => { + await ensureHousehold(page); + await firstVisible(page.getByRole('button', {name: 'Add someone to your household'})).click({timeout: 8000}); + await tid(page, 'add-member-name').fill(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 ${GROWNUP.name} this code`)).waitFor({timeout: 15000}); + await tid(page, 'add-grownup-also-here').waitFor({timeout: 8000}); + await tid(page, 'pair-countdown').waitFor({timeout: 15000}); + await sleep(SETTLE_MS); + await page.mouse.move(0, 0); + await shoot(page, HOUSE_SHOTS.addGrownUp); + await tid(page, 'add-grownup-done').click({timeout: 8000}); + await tid(page, 'add-member-dialog').waitFor({state: 'hidden', timeout: 8000}); + }); + } + + 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 + // as tall as all of it, and no taller). + await step('household: page (light)', async () => { + const height = 1650; + const light = await sameDevice(ctx, {viewport: {width: 1440, height}, 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}); + // Leo's and Ruth's cards: Ruth comes from the add-a-grown-up step, which can fail + // before it saves; without both cards the shot keeps its previous image. + const members = tid(p, 'household-members'); + for (const name of [KID.name, GROWNUP.name]) { + await firstVisible(members.getByRole('heading', {name, exact: true})).waitFor({timeout: 15000}); + } + // Leo's devices, read with Leo's own token here, mark this browser: no skeleton rows. + await firstVisible(members.getByText('This device', {exact: true})).waitFor({timeout: 15000}); + await scrollToTop(members); + // Everything from "Your household" down is shorter than the window, so the scroll + // stops with a strip of the card above showing: take that off the window's + // height and scroll again. + const box = await members.boundingBox(); + const scroller = await tid(p, 'household-scroll').boundingBox(); + const extra = box && scroller ? Math.floor(box.y - scroller.y - 16) : 0; + if (extra > 0) { + await p.setViewportSize({width: 1440, height: height - extra}); + await scrollToTop(members); + } + await p.mouse.move(0, 0); + 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 openHousehold(p, 'you?section=household'); + 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 ensureHousehold(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.profiles)) { await step('household: who is listening', async () => { + await ensureHousehold(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); @@ -1309,21 +1503,20 @@ async function captureHouse(house) { }); } - if (wanted(HOUSE_SHOTS.kids, HOUSE_SHOTS.phoneKids)) { + if (wanted(HOUSE_SHOTS.kids, HOUSE_SHOTS.phoneKids) && kid) { // The child, chosen on "Who's listening?" (a reload asks), with a story loaded; then // the same device at phone width. await step('household: kids', async () => { - await kidsStaged(page, kidsName); + await kidsStaged(page, kid.name); await shoot(page, HOUSE_SHOTS.kids); }); 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); + await kidsStaged(p, kid.name); await shoot(p, HOUSE_SHOTS.phoneKids); } finally { await phone.close(); diff --git a/screenshots/lib.mjs b/screenshots/lib.mjs index f405840..4e031da 100644 --- a/screenshots/lib.mjs +++ b/screenshots/lib.mjs @@ -120,3 +120,46 @@ export async function step(name, fn) { console.log(` ✗ ${name}: ${String(e && e.message ? e.message : e).split('\n')[0]}`); } } + +// A clip around `boxes` (each from boundingBox()), `pad` CSS pixels wider on every +// side, never starting off the page. +export const around = (boxes, pad = 16) => { + const x = Math.max(0, Math.min(...boxes.map((b) => b.x)) - pad); + const y = Math.max(0, Math.min(...boxes.map((b) => b.y)) - pad); + const right = Math.max(...boxes.map((b) => b.x + b.width)) + pad; + const bottom = Math.max(...boxes.map((b) => b.y + b.height)) + pad; + return {x, y, width: right - x, height: bottom - y}; +}; + +// A household for a capture, made as its owner (`token`) through the player's own API +// (see README.md): a collection of theirs named `collection` holding `items` (rel paths +// in library `libraryId`; none without a name), and `members` ({name, kind, access}), +// each on "collections" given that collection. What a previous run left on the account +// (a collection or a member of the same name) is reused, so a rerun adds nobody and +// spends none of the server's self-service rate limit. Resolves to +// {collectionId, members: [{...member spec, id, username}]}. +export async function addHousehold(api, token, libraryId, {collection, items = [], members = []}) { + let collectionId = null; + if (collection) { + const owned = ((await api(token, 'GET', '/me/collections'))?.collections ?? []).find( + (c) => c.owned && c.name === collection, + ); + collectionId = owned?.id ?? (await api(token, 'POST', '/me/collections', {name: collection})).collection.id; + await api(token, 'PUT', `/me/collections/${collectionId}/items`, { + items: items.map((p) => ({library_id: libraryId, path: p})), + }); + } + const existing = members.length ? ((await api(token, 'GET', '/me/household/members'))?.members ?? []) : []; + const made = []; + for (const m of members) { + const shares = m.access === 'collections' && collectionId ? {collection_ids: [collectionId]} : {}; + let member = existing.find((x) => x.name === m.name); + if (member) { + if (shares.collection_ids) member = await api(token, 'PATCH', `/me/household/members/${member.id}`, shares); + } else { + ({member} = await api(token, 'POST', '/me/household/members', {name: m.name, kind: m.kind, access: m.access, ...shares})); + } + made.push({...m, id: member.id, username: member.username}); + } + return {collectionId, members: made}; +} diff --git a/screenshots/manifest.mjs b/screenshots/manifest.mjs index 6bcb97f..63b343e 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