---
type: plan
version: 1
status: written 2026-09-10 by Stream R; six diffs for rian to apply upstream, then re-vendor with main/scripts/vendor-refresh.sh
---
# Kit upstream proposals from the accounts stream

A vendoring app found these while vendoring the BW application kit byte-identical. Each one
is worked around in the app without editing a vendored byte. Applying them upstream lets the
app drop its workaround on the next vendor-refresh. This file names no app.

Every diff below is a unified diff, verified to apply cleanly with `patch -p1` run from
`/srv/system/id-auth/app-auth/` (the kit root). See "Verification" at the end for how each
one was checked.

## 1. `bw_accounts.py`: a silently defaulted owner

`init()` takes `owner="rian"` as a default, and `_state["owner"] = (owner or "rian").strip().lower()`
falls back to `rian` on any falsy value (empty string, `None`, `0`). In a copied kit, that is a
footgun: a caller that forgets the `owner=` argument, or passes it from a config value that
turns out empty, silently gets `rian` as the super admin instead of an error. A vendoring app
had to pass a sentinel string into `owner=` just to be sure a missing value would be caught
early rather than wired to the wrong account.

Proposal: make `owner` required (no default) and raise `AccountsError("init() needs an
owner.", "NOT_INITIALIZED")` when it is falsy after `strip()`.

```diff
--- a/bw_accounts.py
+++ b/bw_accounts.py
@@ -357,3 +357,3 @@
 
-def init(db_path=None, owner="rian", has_instances=False, store=None, audit=None):
+def init(db_path=None, owner=None, has_instances=False, store=None, audit=None):
     """Wire up the kit. Call once at app startup.
@@ -363,3 +363,3 @@
                        missing). Ignored when `store` is given.
-        owner:         the app owner = immutable super admin (BW username).
+        owner:         the app owner = immutable super admin (BW username, REQUIRED).
         has_instances: True if this app has instances (projects/workspaces).
@@ -378,4 +378,7 @@
         store = SqliteStore(db_path)
+    owner = (owner or "").strip().lower()
+    if not owner:
+        raise AccountsError("init() needs an owner.", "NOT_INITIALIZED")
     _state["store"] = store
-    _state["owner"] = (owner or "rian").strip().lower()
+    _state["owner"] = owner
     _state["has_instances"] = bool(has_instances)
```

## 2. `react-admin/AccountMenu.tsx`: a hardcoded brand string

The signed-in menu header always renders the literal `"Bowden Works account"` under the
username, with no way to change it short of forking the component.

Proposal: an optional `accountLabel?: string` prop, defaulting to the current text, so an app
can relabel the menu without forking.

```diff
--- a/react-admin/AccountMenu.tsx
+++ b/react-admin/AccountMenu.tsx
@@ -16,4 +16,5 @@
   signOutHref?: string;
+  accountLabel?: string;
 }) {
-  const { onOpenAdmin, onOpenProfile, onSignOut, signOutHref } = props;
+  const { onOpenAdmin, onOpenProfile, onSignOut, signOutHref, accountLabel } = props;
   const me = useMe();
@@ -58,3 +59,3 @@
             <div className="bw-menu-name">{username}</div>
-            <div className="bw-menu-sub">Bowden Works account</div>
+            <div className="bw-menu-sub">{accountLabel || "Bowden Works account"}</div>
           </div>
```

## 3. `react-admin/AddPerson.tsx`: three hardcoded "BW account" strings

The add-person flow has three literal brand strings baked into its copy: `"has a BW account"`
(the existing-account case), the "no existing account" prompt naming BW by name, and "the
central directory is unavailable" (the no-account case). An app that doesn't call its identity
provider "BW" has no way to say so.

Proposal: an optional `strings?:` prop carrying the three overridable pieces, each defaulting
to the current text, following the same pattern as proposal 2.

```diff
--- a/react-admin/AddPerson.tsx
+++ b/react-admin/AddPerson.tsx
@@ -43,2 +43,8 @@
   hereLabel?: string;
+  /** Override the BW-branded copy without forking the component. */
+  strings?: {
+    hasAccount?: string;
+    noAccountPrefix?: string;
+    directoryUnavailable?: string;
+  };
 }) {
@@ -201,3 +207,3 @@
             <span className="bw-muted">
-              has a BW account
+              {props.strings?.hasAccount ?? "has a BW account"}
               {candidate.profile &&
@@ -237,4 +243,4 @@
             {candidate.checked
-              ? `There isn’t an existing BW account for “${candidate.username || newEmail}”. Create one and invite them?`
-              : "The central directory is unavailable — they’ll be added without an invite email."}
+              ? `${props.strings?.noAccountPrefix ?? "There isn’t an existing BW account for"} “${candidate.username || newEmail}”. Create one and invite them?`
+              : (props.strings?.directoryUnavailable ?? "The central directory is unavailable — they’ll be added without an invite email.")}
           </span>
```

## 4. `react-admin/AdminApp.tsx`: the Status tab can't be turned off

`AdminApp` always pushes the Status tab (`AuthStatusPanel`) onto the tab list for any viewer
who can see any admin tab. `AuthStatusPanel` reports central-sync state and cross-app access,
which only means something for an app that actually syncs with the central directory.

Proposal: a `showStatus?: boolean` prop defaulting to `true`; when `false` the Status tab is
not offered. An app with no central sync has nothing to show there.

```diff
--- a/react-admin/AdminApp.tsx
+++ b/react-admin/AdminApp.tsx
@@ -22,2 +22,3 @@
   instanceNounPlural?: string;
+  showStatus?: boolean;
 }) {
@@ -45,5 +46,7 @@
     }
-    list.push({ key: "status", label: "Status", render: () => <AuthStatusPanel /> });
+    if (props?.showStatus ?? true) {
+      list.push({ key: "status", label: "Status", render: () => <AuthStatusPanel /> });
+    }
     return list;
-  }, [me, noun, nounPlural]);
+  }, [me, noun, nounPlural, props?.showStatus]);
```

## 5. `react-admin/ProfilePanel.tsx`: the account button always opens a popup

The "Manage account and password" button always calls `openAccountPopup`, which opens a
small `window.open(url, "bw-account", "popup=yes,width=460,height=640,...")` window. Some
embedding contexts (popup blockers, kiosk-mode browsers, an app already running inside its
own popup) can't rely on a nested popup opening reliably.

Proposal: an `openInPopup?: boolean` prop defaulting to `true`; when `false` it navigates the
current tab instead.

```diff
--- a/react-admin/ProfilePanel.tsx
+++ b/react-admin/ProfilePanel.tsx
@@ -35,4 +35,5 @@
 
-export function ProfilePanel(props?: { embedUrl?: string }) {
+export function ProfilePanel(props?: { embedUrl?: string; openInPopup?: boolean }) {
   const embedUrl = props?.embedUrl;
+  const openInPopup = props?.openInPopup ?? true;
   const me = useMe();
@@ -102,3 +103,7 @@
             className="bw-btn"
-            onClick={() => openAccountPopup(accountTarget)}
+            onClick={() =>
+              openInPopup
+                ? openAccountPopup(accountTarget)
+                : (window.location.href = accountTarget)
+            }
           >
```

## 6. Eight unused default `React` imports, and a stricter kit tsconfig

`grep -ln "^import React" react-admin/*.tsx` matches twelve files. Checking each for any
other use of the `React` identifier (the pack targets the automatic JSX runtime, so the
default import is only needed where `React.something` is actually referenced):

- **Keep the import**, `React.` is referenced: `AdminApp.tsx` (`React.ReactNode`),
  `LevelsPanel.tsx` (`React.Fragment`), `useBwAuth.tsx` (`React.ReactNode`),
  `ViewAsBanner.tsx` (`React.PointerEvent`).
- **Drop the import**, `React` is never referenced beyond the import line itself:
  `AuthStatusPanel.tsx`, `AddPerson.tsx`, `MatrixPanel.tsx`, `AdminTable.tsx`,
  `UserPicker.tsx`, `PermPicker.tsx`, `PeoplePanel.tsx`, `InstanceMembers.tsx`: eight files.

Each of those eight fails TS6133 (`'React' is declared but its value is never read`) under a
vendoring app's own `noUnusedLocals`. The app currently compiles the vendored folder under a
separate tsconfig project reference to dodge that, rather than fix the eight files.

Proposal: drop the unused default import in the eight files, and add a `tsconfig.json` to the
kit's own `react-admin/` folder with `noUnusedLocals` and `noUnusedParameters` on, so an
unused import like this is caught upstream before it ships to every vendoring app.

```diff
--- a/react-admin/AuthStatusPanel.tsx
+++ b/react-admin/AuthStatusPanel.tsx
@@ -6,3 +6,3 @@
 
-import React, { useCallback, useState } from "react";
+import { useCallback, useState } from "react";
 import { useBwApi, useMe } from "./useBwAuth";
--- a/react-admin/AddPerson.tsx
+++ b/react-admin/AddPerson.tsx
@@ -13,3 +13,3 @@
 
-import React, { useCallback, useState } from "react";
+import { useCallback, useState } from "react";
 import { UserPicker } from "./UserPicker";
--- a/react-admin/MatrixPanel.tsx
+++ b/react-admin/MatrixPanel.tsx
@@ -5,3 +5,3 @@
 
-import React, { useCallback, useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
 import { useBwApi, useMe } from "./useBwAuth";
--- a/react-admin/AdminTable.tsx
+++ b/react-admin/AdminTable.tsx
@@ -5,3 +5,3 @@
 
-import React, { useMemo, useState } from "react";
+import { useMemo, useState } from "react";
 import { AdminTableProps, Column } from "./types";
--- a/react-admin/UserPicker.tsx
+++ b/react-admin/UserPicker.tsx
@@ -5,3 +5,3 @@
 
-import React, { useEffect, useRef, useState } from "react";
+import { useEffect, useRef, useState } from "react";
 import { useDebounced } from "./useBwAuth";
--- a/react-admin/PermPicker.tsx
+++ b/react-admin/PermPicker.tsx
@@ -8,3 +8,3 @@
 
-import React, { useMemo, useState } from "react";
+import { useMemo, useState } from "react";
 import { PermissionInfo } from "./types";
--- a/react-admin/PeoplePanel.tsx
+++ b/react-admin/PeoplePanel.tsx
@@ -15,3 +15,3 @@
 
-import React, { useCallback, useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
 import { useBwApi, useMe } from "./useBwAuth";
--- a/react-admin/InstanceMembers.tsx
+++ b/react-admin/InstanceMembers.tsx
@@ -14,3 +14,3 @@
 
-import React, { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
 import { useBwApi } from "./useBwAuth";
--- /dev/null
+++ b/react-admin/tsconfig.json
@@ -0,0 +1,19 @@
+{
+  "compilerOptions": {
+    "target": "ES2020",
+    "module": "ESNext",
+    "moduleResolution": "Bundler",
+    "jsx": "react-jsx",
+    "lib": ["ES2020", "DOM", "DOM.Iterable"],
+    "types": ["react", "react-dom"],
+    "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "noEmit": true,
+    "esModuleInterop": true,
+    "isolatedModules": true,
+    "skipLibCheck": true,
+    "resolveJsonModule": true,
+    "forceConsistentCasingInFileNames": true
+  },
+  "include": ["."]
+}
```

## A vendor drift check for the scaffolder

A vendored copy on this server had already drifted from the kit unnoticed before this stream
caught it by hand. "Never hand-edited" needs a check, not a promise.

Proposal: `new-bw-app.sh` gains a `--check-vendor` mode that byte-compares an app's vendored
copies of the four kit modules (`bw_auth.py`, `bw_accounts.py`, `bw_view_as.py`,
`bw_admin_api.py`) and the whole `react-admin/` pack against the kit's own copies, and exits 1
on any difference, printing the differing paths. It reuses the same source and destination
paths the scaffolder already vendors from and to (`$KIT_DIR` and `$DIR/main/app/`,
`$DIR/main/frontend/src/bw-admin/`), so it can never drift from what the scaffolder itself
copies.

```bash
check_vendor() {
  local dir="$1" kit="$KIT_DIR" bad=0
  for mod in bw_auth bw_accounts bw_view_as bw_admin_api; do
    cmp -s "$kit/$mod.py" "$dir/main/app/$mod.py" || { echo "drift: app/$mod.py"; bad=1; }
  done
  for f in "$kit"/react-admin/*.ts "$kit"/react-admin/*.tsx "$kit"/react-admin/*.css; do
    base="$(basename "$f")"
    cmp -s "$f" "$dir/main/frontend/src/bw-admin/$base" \
      || { echo "drift: frontend/src/bw-admin/$base"; bad=1; }
  done
  [ "$bad" -eq 0 ] || exit 1
}
```

## Learnings for the kit (nameless, past tense)

An app that had to stay transferable kept its identity table free of any central coupling and
vendored the kit with a hash manifest so a hand edit failed the suite even after the upstream
path was gone. A silently defaulted owner name is a footgun in a copied kit: the owner should
be required. The conformance pack proved cheapest when its fixture contract, not its
transport, was the seam. A vendorable React pack must compile under the strictest tsconfig a
vendoring app is likely to run. A secret in a URL path or query is a secret in the access log:
invite and reset tokens ride in the link's fragment and a JSON body.

## Verification

Each diff was tested by copying `/srv/system/id-auth/app-auth/` (read-only source, never
touched) into a scratch directory and running `patch -p1 --dry-run` there with each diff
extracted to its own file, then re-run for real (still only against the scratch copy) to
confirm the resulting files parse. All six diffs applied cleanly, individually and in
sequence against the same tree, with no fuzz and no rejected hunks:

| # | Diff | Applied clean |
|---|---|---|
| 1 | `bw_accounts.py` | yes |
| 2 | `react-admin/AccountMenu.tsx` | yes |
| 3 | `react-admin/AddPerson.tsx` | yes |
| 4 | `react-admin/AdminApp.tsx` | yes |
| 5 | `react-admin/ProfilePanel.tsx` | yes |
| 6 | eight import removals + new `react-admin/tsconfig.json` | yes |

`python3 -c "import ast; ast.parse(...)"` confirmed the patched `bw_accounts.py` still parses.
The scratch copy was discarded after verification; nothing under `/srv/system/` was modified.
