fix: address code review on #27

- The empty state is about an empty library, not a brand-new Reader:
  listView.Fresh becomes EmptyLibrary and moves behind the tab-specific
  branches, so "No favourites yet" is no longer shadowed for a Reader whose
  library happens to be empty. The action key stays put — hiding it was
  never asked for.
- The owner is not a revocable Reader: their row offers no button and
  POST /readers/{owner}/revoke is a 404, so the one row where the control
  would sign out the tapping browser cannot be reached by a hand-rolled
  POST either.
- Modify isolation is asserted in both directions, and the owner's own
  sign-in through the OAuth callback is pinned to the seeded row.
- CUTOVER.md and REDEPLOY.md still grepped API_TOKEN out of .env for their
  smoke tests, which the last commit deleted; both now take the acting
  Reader's derived credential.
- Roster type follows the machine-fact spec (500 10-11px mono, tracked),
  and PRODUCT.md names the Readers panel instead of claiming there is no
  owner surface at all.
This commit is contained in:
2026-08-08 20:14:21 +07:00
parent b0bf6fe770
commit f4f6c9c9e9
10 changed files with 115 additions and 40 deletions
+23 -12
View File
@@ -72,16 +72,19 @@ type listView struct {
// Rotated marks the setup panel as having just rotated the credential:
// it swaps the reinstall warning in over the button row.
Rotated bool
// Fresh means this Reader has no bookmarks at all, in either library — a
// brand-new registration rather than an empty bucket. The empty state then
// explains how a library gets filled instead of describing a filter.
Fresh bool
// EmptyLibrary means this Reader holds no bookmarks in either library, so
// the empty state can offer the installs instead of reporting on a filter.
// It is not "newly registered": a Reader who deletes their last bookmark is
// in the same position and needs the same links.
EmptyLibrary bool
// Owner marks the acting Reader as the deployment's owner, which unlocks
// the Readers panel. Nothing else in the UI differs.
Owner bool
// Readers is the owner's roster, populated only for the owner's own page
// render and the revocation fragment.
// render and the revocation fragment. OwnerID travels with it so the roster
// can tell the owner's own row apart from the Readers they may revoke.
Readers []store.ReaderSummary
OwnerID int64
}
// PageURL and ListURL are the two link shapes every tab needs. Building them
@@ -238,7 +241,7 @@ func (h *Handler) index(w http.ResponseWriter, r *http.Request) {
return
}
if readerID == h.store.OwnerID() {
view.Owner = true
view.Owner, view.OwnerID = true, readerID
if view.Readers, err = h.store.Readers(); err != nil {
log.Printf("index readers: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
@@ -292,10 +295,10 @@ func (h *Handler) buildListView(readerID int64, lib, tab string) (listView, erro
if err != nil {
return listView{}, err
}
// Fresh is about the Reader, not the library, so it is taken before the
// filter narrows the slice: it decides whether an empty list reads as
// "install the scripts" or "this bucket is empty".
fresh := len(all) == 0
// Taken before the filter narrows the slice: a Reader with novels but no
// manga has a working install already, and does not need to be told to go
// and get one.
emptyLibrary := len(all) == 0
// Narrow to one library first: reading, withNew and recent all derive from
// this slice, so doing it later would let the other library's rows into the
// strip and the Updated badge.
@@ -340,7 +343,7 @@ func (h *Handler) buildListView(readerID int64, lib, tab string) (listView, erro
}
}
return listView{Lib: lib, Tab: tab, Recent: recent, Items: items,
NewCount: len(withNew), Fresh: fresh}, nil
NewCount: len(withNew), EmptyLibrary: emptyLibrary}, nil
}
func (h *Handler) uiList(w http.ResponseWriter, r *http.Request) {
@@ -631,6 +634,14 @@ func (h *Handler) revokeReaderSessions(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad reader id", http.StatusBadRequest)
return
}
// The owner is not one of the Readers this endpoint reaches: revoking
// themselves would sign out the browser making the request, which is what
// logout is for. The roster hides the button; this refuses the hand-rolled
// POST behind it.
if target == h.store.OwnerID() {
http.NotFound(w, r)
return
}
if err := h.store.DeleteReaderSessions(target); err != nil {
log.Printf("revoke sessions: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
@@ -642,5 +653,5 @@ func (h *Handler) revokeReaderSessions(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.render(w, http.StatusOK, "readers", listView{Owner: true, Readers: readers})
h.render(w, http.StatusOK, "readers", listView{Owner: true, Readers: readers, OwnerID: h.store.OwnerID()})
}