@aiquants/auth-react-router 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -81,15 +81,86 @@ export const userAdminApp = createUserAdminApp({
81
81
  import { AuthUserAdminAppView } from "@aiquants/auth-react-router/admin"
82
82
  export const loader = userAdminApp.loader
83
83
  export const action = userAdminApp.action
84
- export default () => <AuthUserAdminAppView />
84
+ // mounted as the splat route "/users/*"
85
+ export default () => <AuthUserAdminAppView basePath="/users" />
85
86
  ```
86
87
 
88
+ - **`basePath` is required** (`AuthUserAdminAppView` / `UserAdminShell`): the absolute path the console is mounted at,
89
+ without a trailing slash (`"/users"` for a `/users/*` route). The five tabs and the per-row "members" links of the
90
+ groups tab are built from it. There is no default: the host decides where the console lives, and a built-in default
91
+ would keep drawing links to that default after the mount moves — the routes still resolve and the build still
92
+ passes, only the links point at a place that is not served.
93
+
87
94
  - **`guards` is mandatory** — this surface lists every user and mutates group membership, so an unguarded mount is an account-enumeration and privilege-escalation path. Allow by returning, deny by throwing (`Response` / `redirect` / `Error`); the return value is `void` by contract, so a predicate-style guard that returns `false` cannot silently fail open.
88
95
  - **Per-operation authorization**: the guard receives the CRUD verb each intent actually performs (`read` / `create` / `update` / `delete`), so a principal holding only `update` cannot delete. Membership add/remove map to `create`/`delete` because they add and remove rows.
89
96
  - Unknown intents reach neither the guard nor the store (the intent table is a `Map`, so prototype keys such as `constructor` do not resolve).
90
97
  - Store errors (e.g. "cannot remove the last administrator") are returned as `{ ok: false, error }` and rendered by the views; guard rejections propagate untouched.
91
98
  - Labels default to **English** (`defaultUserAdminLabels`); inject `jaUserAdminLabels` or a partial override via `resolveUserAdminLabels`.
92
99
 
100
+ ### Localization (`labels.locale` vs `labels.formatLocale`)
101
+
102
+ Every string this package draws lives in `UserAdminLabels`, and a host picks the language by injecting a whole catalog
103
+ (`defaultUserAdminLabels` for English, the package default, or `jaUserAdminLabels`) or a partial override. Two keys
104
+ carry language, and they are **different axes**:
105
+
106
+ | Key | Type | `defaultUserAdminLabels` | `jaUserAdminLabels` | Drives |
107
+ | --- | --- | --- | --- | --- |
108
+ | `locale` | `UserAdminLocale` (`"en"` \| `"ja"`) | `"en"` | `"ja"` | UI chrome language of both upstream-group pickers (`SelectBox` `locale`) |
109
+ | `formatLocale` | BCP 47 `string` | `"en-US"` | `"ja-JP"` | `Intl.DateTimeFormat` (with `timeZone`) and the user-list `Intl.Collator` |
110
+
111
+ - **`locale`** is forwarded to both `@aiquants/select-box` pickers (the create-group dialog and the directory link
112
+ form). The picker strings this package owns (`directoryView.pickerNoOptions` / `pickerClear` / `pickerToggle` /
113
+ `pickerSelected` / `pickerAvailable`) are passed as the picker's `labels`, the placeholder is the per-instance
114
+ `placeholder` prop, and every other key comes from the select-box catalog of `locale` (the values below are those of
115
+ `defaultUserAdminLabels` / `jaUserAdminLabels` and of select-box 0.10.0):
116
+
117
+ | select-box key | Source | `"en"` | `"ja"` |
118
+ | --- | --- | --- | --- |
119
+ | `noOptions` | `directoryView.pickerNoOptions` | No matching group | 一致するグループがありません |
120
+ | `clear` | `directoryView.pickerClear` | Clear the selection | 選択を解除 |
121
+ | `toggle` | `directoryView.pickerToggle` | Show the group list | グループ一覧を開く |
122
+ | `selection` | `directoryView.pickerSelected` (`{value}` = the picked option's label) | Selected: {value} | 選択中: {value} |
123
+ | `status` | `directoryView.pickerAvailable` (`{count}` = options shown) | {count} groups available | 候補 {count} 件 |
124
+ | `placeholder` | `placeholder` prop: `groupsView.pickUpstreamPlaceholder` (dialog) / `directoryView.pickUpstreamPlaceholder` (link form) | Search by name or address... | 名前かアドレスで検索... |
125
+ | `scrollUp` | select-box catalog | Scroll up | 上へスクロール |
126
+ | `scrollDown` | select-box catalog | Scroll down | 下へスクロール |
127
+ | `scrollLeft` | select-box catalog | Scroll left | 左へスクロール |
128
+ | `scrollRight` | select-box catalog | Scroll right | 右へスクロール |
129
+ | `scrollToTop` | select-box catalog | Top | 先頭へ |
130
+ | `scrollToBottom` | select-box catalog | Bottom | 末尾へ |
131
+ | `noItems` | select-box catalog | No items | 項目がありません |
132
+ | `removeTag` | select-box catalog (multi-select chips; both pickers are single-select) | Remove {label} | 「{label}」を削除 |
133
+ | `resizeHandle` | select-box catalog (tooltip of the option list's resize handle) | Resize handle | サイズ変更ハンドル |
134
+
135
+ To change a catalog-sourced string, there is no per-key override here: pick the `locale` whose catalog you want.
136
+
137
+ - `resolveUserAdminLabels` (run once when `createUserAdminApp` is built) **validates `locale`**: an omitted value keeps
138
+ the English default `"en"`; `"en"` / `"ja"` pass; anything else (`"ja-JP"`, `"EN"`, `""`, `null`, ...) throws
139
+ `RangeError` naming `labels.formatLocale`, so a mistake stops the app at startup instead of crashing the picker on
140
+ first render. There is no language negotiation — map `navigator.language` or a user setting to `"en"` / `"ja"`
141
+ yourself.
142
+ - **`formatLocale` is not validated**: an unreadable tag degrades (dates fall back to the raw ISO string, sorting to the
143
+ runtime collator) instead of throwing, because formatting must never take the screen down.
144
+
145
+ #### Migrating from 0.15.x (breaking changes in 0.16.0)
146
+
147
+ 1. **`basePath` is now required** on `AuthUserAdminAppView` / `UserAdminShell` (see above). Pass the absolute mount
148
+ path, without a trailing slash: `<AuthUserAdminAppView basePath="/users" />`. Omitting it fails type-checking
149
+ (TS2741); an untyped caller would draw links such as `undefined/groups`.
150
+ 2. **`labels.locale` changed meaning**: the old `labels.locale` (a BCP 47 tag for dates and sorting) is now
151
+ `labels.formatLocale` — no alias. The new `labels.locale` (type `UserAdminLocale` = `"en"` | `"ja"`) is the UI
152
+ language of the pickers, validated at startup.
153
+ - Hosts that inject `jaUserAdminLabels` whole, or spread it (`{ ...jaUserAdminLabels, heading: "" }`), need no
154
+ change: it carries `locale: "ja"` and `formatLocale: "ja-JP"`.
155
+ - A host that overrode `locale: "ja-JP"` must write `formatLocale: "ja-JP"`, and add `locale: "ja"` if it relies
156
+ on Japanese picker chrome (without it the pickers' catalog-sourced strings, such as the scroll-arrow names, are
157
+ English).
158
+ - A host that builds a full `UserAdminLabels` literal must add both keys.
159
+ 3. **`@aiquants/select-box` `texts` is gone**: the pickers take select-box 0.10.0 `locale` / `labels`
160
+ (`SelectBoxLabelOverrides`) instead of `texts` / `SelectBoxTexts`. This is internal to the views — hosts that only
161
+ render `AuthUserAdminAppView` do nothing — but a host must install `@aiquants/select-box` >= 0.10.0 and
162
+ `@aiquants/virtualscroll` >= 3.7.0 (the new peer floors).
163
+
93
164
  ### Upstream group catalog (optional port)
94
165
 
95
166
  The groups and directory tabs let the operator **pick** an upstream group instead of spelling its address. Wire the optional
@@ -146,6 +217,22 @@ domain with no groups.
146
217
  - `createLinkedGroup` creates the group and links it in **one** intent, and therefore requires the union of the verbs both
147
218
  halves need (`create` + `update` + `delete`). Splitting it in two would leave a state where only one half succeeded.
148
219
 
220
+ ### Sync requests: queued on link, followed through on screen
221
+
222
+ The console never reaches the upstream itself. "Sync now" — and, since 0.15.0, **every link creation** — queues a request
223
+ for the synchronization job (`store.requestDirectorySync(groupId, actor)`), and the job writes the result back to the
224
+ database a few seconds later.
225
+
226
+ - Both link intents return `{ ok: true, syncRequest }` with `"queued"`, `"already-pending"`, or `"not-queued"`. The last
227
+ one means **the link succeeded but the request could not be queued**; it is reported as such (and logged) rather than
228
+ thrown, because throwing would report a failure for a link that now exists. The request is scoped to the group, not the
229
+ tenant, so linking one group does not re-sweep every link.
230
+ - While `directoryStatus.pendingRequestCount > 0`, the shell re-validates the route data every `intervalMs` and stops
231
+ after `maxMs` if the count never changes (a deployment with no job consuming the queue must not be polled forever from
232
+ every open tab). Configure it with the optional `pendingSyncPolling` prop of `AuthUserAdminAppView` / `UserAdminShell`;
233
+ the default is `DEFAULT_PENDING_SYNC_POLLING` (3 s, up to 5 min). Nothing here adds an upstream call: the
234
+ re-validation reads the rows the job wrote.
235
+
149
236
  Styling: this package has **no hand-written component CSS** (the `GoogleForm` classes are plain Tailwind utilities), so it ships **no components-only artifact** — only a standalone build. Two consumption modes, never mixed:
150
237
 
151
238
  - **Tailwind v4 host** — add `@source "../node_modules/@aiquants/auth-react-router/src/**/*.{ts,tsx}";` (monorepo: `../../../../packages/auth-react-router/src/**/*.{ts,tsx}`) so the `GoogleForm` classes are generated in the host's own canonical build; `src` ships in the published package.
@@ -164,11 +251,17 @@ the host, or the group picker's dropdown renders unstyled:
164
251
  @import "@aiquants/select-box/styles/select-box.css";
165
252
  ```
166
253
 
167
- Its transitive peers (`@aiquants/fuzzy-search`, `@aiquants/virtualscroll`) are declared here as well, so a host that
168
- installs this package is told what the picker needs rather than discovering it as a runtime resolution failure.
169
-
170
- All three are **required** peers, not optional. `src/admin/views.tsx` imports `@aiquants/select-box` at the top level and
171
- `./admin` re-exports it, so the specifier survives into `dist/admin.mjs`: a host without it does not get a degraded
254
+ `@aiquants/fuzzy-search` is a transitive peer (the picker's search index, reached only through select-box) and is
255
+ declared here as well, so a host that installs this package is told what the picker needs rather than discovering it
256
+ as a runtime resolution failure. `@aiquants/virtualscroll` is both: select-box renders its option list with it, and
257
+ this package imports it **directly** — `src/admin/labels.ts` resolves the UI language with
258
+ `resolveVirtualScrollLocale`, so `dist/admin.mjs` carries its specifier. The pickers use the `locale` / `labels` props
259
+ of `@aiquants/select-box` 0.10.0 and the locale resolution of `@aiquants/virtualscroll` 3.7.0, so those are the peer
260
+ floors.
261
+
262
+ All three are **required** peers, not optional. `src/admin/views.tsx` imports `@aiquants/select-box` and
263
+ `src/admin/labels.ts` imports `@aiquants/virtualscroll` at the top level, and `./admin` re-exports both modules, so the
264
+ specifiers survive into `dist/admin.mjs`: a host without them does not get a degraded
172
265
  picker, it gets `ERR_MODULE_NOT_FOUND` for the whole admin surface — including the groups and allowlist tabs, which have
173
266
  nothing to do with the picker. Marking them optional would suppress the install warning that is the only thing standing
174
267
  between a consumer and that failure. Note also that `@aiquants/select-box` pins React 19, which is stricter than this
@@ -189,7 +282,7 @@ package's own `react: ">=18"`; a host that mounts `/admin` must satisfy the stri
189
282
  | Port | App A | App B |
190
283
  | --- | --- | --- |
191
284
  | `google.scopes` | default | + `directory.readonly` etc. |
192
- | `mockUser` | `mock_user_id` cookie parse + name map | fixed id |
285
+ | `mockUser` | cookie parse + name map | fixed id |
193
286
  | `warmup` | 2 pools | 1 pool |
194
287
  | `backendAuth` | primary-api | secondary-api |
195
288
  | `isUserActive` | DB lookup + allowlist re-check | omitted (always active) |