@aiquants/auth-react-router 0.13.0 → 0.14.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.
@@ -1,9 +1,19 @@
1
+ /**
2
+ * @module admin/views
3
+ * @description The five tab views of the user administration screen.
4
+ * ユーザー管理画面を構成する 5 つのタブビュー。
5
+ *
6
+ * 一覧・検索・所属・許可リスト・ディレクトリ連携を描く。データはすべて loader から渡され、
7
+ * 書き込みはビュー唯一の fetcher (`useSubmitThen`) を通す。
8
+ */
1
9
  import type { AuthAllowlistEntry, AuthDirectoryMembershipMode, AuthDirectoryStatus, AuthGroup, AuthGroupMember, AuthUser } from "@aiquants/auth-core"
10
+ import { getHighlightSegments, type Option, type RenderOptionContext, SelectBox, type SelectBoxTexts } from "@aiquants/select-box"
2
11
  import type React from "react"
3
- import { useEffect, useRef, useState } from "react"
4
- import { useFetcher, useLoaderData } from "react-router"
12
+ import { useEffect, useMemo, useRef, useState } from "react"
13
+ import { Link, useFetcher, useLoaderData, useSearchParams } from "react-router"
5
14
  import { fill, formatDateTime } from "./format"
6
15
  import { defaultUserAdminLabels, type UserAdminLabels } from "./labels"
16
+ import { normalizeUpstreamKey, type UserAdminCatalog, type UserAdminCatalogGroup } from "./server"
7
17
  import { UserAdminShell, type UserAdminShellProps } from "./ui"
8
18
 
9
19
  export type UserAdminLoaderData = {
@@ -13,13 +23,11 @@ export type UserAdminLoaderData = {
13
23
  groupMembers: AuthGroupMember[]
14
24
  allowlist: AuthAllowlistEntry[]
15
25
  directoryStatus: AuthDirectoryStatus
26
+ /** 上流の候補一覧。選択欄を出す面でのみ `ready` になる。 */
27
+ catalog: UserAdminCatalog
16
28
  labels?: UserAdminLabels
17
29
  }
18
30
 
19
- /**
20
- * View for managing users (list, search, toggle active, modal edit).
21
- * ユーザー管理ビュー (一覧・検索・アクティブ切替・ダイアログ編集)。
22
- */
23
31
  /**
24
32
  * Renders the error returned by the last action submission, if any.
25
33
  * 直近の action 送信が返したエラーを表示する部品。
@@ -51,7 +59,7 @@ function ActionError({ result: raw, id }: { result: unknown; id?: string }) {
51
59
  * 通せば後の送信が `pending` を必ず上書きするため、後始末は常に最後の送信のものになる。
52
60
  */
53
61
  function useSubmitThen(fetcher: ReturnType<typeof useFetcher>) {
54
- const pending = useRef<(() => void) | null>(null)
62
+ const pending = useRef<((data: unknown) => void) | null>(null)
55
63
  // 破棄した結果の世代。`fetcher.data` は宣言的に消せないため、こちらで無効化する
56
64
  const [discarded, setDiscarded] = useState<unknown>(undefined)
57
65
  const result = fetcher.data === discarded ? undefined : fetcher.data
@@ -64,10 +72,13 @@ function useSubmitThen(fetcher: ReturnType<typeof useFetcher>) {
64
72
  // すべての送信がこのフックを通る限り、後の送信が必ず `pending` を上書きするため。
65
73
  // 素の `fetcher.submit` が 1 つでも混ざった瞬間に効き始める
66
74
  pending.current = null
67
- if ((fetcher.data as { ok?: boolean }).ok === true) onSuccess()
75
+ // ⚠️ 後始末には応答そのものを渡す。`ok === true` は「受理した」であって「要求どおりに
76
+ // なった」ではない — 積まれなかった要求も受理として返る。区別できない形にすると、
77
+ // 押した控えだけが成功の見た目で残る
78
+ if ((fetcher.data as { ok?: boolean }).ok === true) onSuccess(fetcher.data)
68
79
  }, [fetcher.state, fetcher.data])
69
80
 
70
- const submit = (payload: Record<string, string>, onSuccess?: () => void) => {
81
+ const submit = (payload: Record<string, string>, onSuccess?: (data: unknown) => void) => {
71
82
  pending.current = onSuccess ?? null
72
83
  setDiscarded(undefined)
73
84
  fetcher.submit(payload, { method: "post" })
@@ -104,14 +115,51 @@ function useModalBehavior(isOpen: boolean, onClose: () => void, openerRef?: Reac
104
115
  // `activeElement` を見るだけでは戻し先にならない。押下で焦点が乗らないブラウザ
105
116
  // (Safari / macOS の Firefox) では body へ戻ってしまう。開いた要素を明示的に覚える
106
117
  const opener = openerRef?.current ?? (document.activeElement as HTMLElement | null)
107
- const focusables = () => Array.from(panel?.querySelectorAll<HTMLElement>('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])') ?? [])
108
- focusables()[0]?.focus()
118
+ /**
119
+ * Tab が実際に止まる要素だけを、文書順で返す。
120
+ *
121
+ * ⚠️ 同じ `name` のラジオ群は、ブラウザにとって **停止点 1 つ** である (選択中の 1 つ)。
122
+ * 両方を輪に入れると 2 つずれる — 先頭で Shift+Tab したときに罠が働かず `aria-modal` の
123
+ * 対話の外へ抜け、末尾から Tab したときは選択されていない 1px のラジオへ焦点が飛ぶ。
124
+ * 初期焦点だけがこの配慮をしていて、輪はしていなかった
125
+ */
126
+ const focusables = () => {
127
+ // ⚠️ 最後の `[tabindex]` 節は、前の節が除いた **無効な要素を連れ戻す**。無効かつ
128
+ // `tabindex="0"` の要素は実在する (仮想スクロールの矢印ボタンがそれで、popup の中に
129
+ // 常に 2 つ出る)。輪の端がそういう要素になると、`preventDefault` の後に `focus()` が
130
+ // 黙って何もしないため、**そこから Tab で出られない** 本物の罠になる。
131
+ // `inert` と非表示の部分木も同様に、到達できない停止点を輪へ持ち込む
132
+ const all = Array.from(panel?.querySelectorAll<HTMLElement>('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])') ?? []).filter(
133
+ (element) => !((element as HTMLElement & { disabled?: boolean }).disabled || element.hasAttribute("inert")) && element.closest("[inert]") === null && element.getAttribute("aria-hidden") !== "true",
134
+ )
135
+ const seenRadioGroups = new Set<string>()
136
+ return all.filter((element) => {
137
+ const radio = element instanceof HTMLInputElement && element.type === "radio" ? element : null
138
+ if (!radio || radio.name === "") return true
139
+ // 群の代表は選択中の 1 つ。どれも選択されていなければ先頭が停止点になる
140
+ if (!radio.checked && all.some((other) => other instanceof HTMLInputElement && other.type === "radio" && other.name === radio.name && other.checked)) return false
141
+ if (seenRadioGroups.has(radio.name)) return false
142
+ seenRadioGroups.add(radio.name)
143
+ return true
144
+ })
145
+ }
146
+ // ⚠️ 初期焦点は **見える** 要素へ置く。1px に切り詰めた入力 (ラジオ群の実体) へ置くと、
147
+ // 焦点環はその 1px の箱に描かれ、操作者からは「どこにも焦点が無い」ようにしか見えない。
148
+ // Tab の輪からは外さない — 見えないだけで、到達できる必要はある。
149
+ // 寸法では判定しない (jsdom は常に 0 を答え、検査が空砲になる)。意図を属性で示す
150
+ const items = focusables()
151
+ ;(items.find((element) => !element.hasAttribute("data-aqar-visually-hidden")) ?? items[0])?.focus()
109
152
 
110
153
  const onKeyDown = (event: KeyboardEvent) => {
111
154
  if (event.key === "Escape") {
112
155
  // 変換中の Escape は「候補を取り消す」であって「閉じる」ではない。ここで
113
156
  // 閉じると、編集中の下書きは状態ごと捨てられ、打ち直しになる
114
157
  if (event.isComposing || event.keyCode === 229) return
158
+ // ⚠️ 内側の部品が既に処理した Escape で対話ごと閉じない。選択欄は「一覧を閉じる」
159
+ // ために Escape を消費し、`preventDefault` で消費済みだと示した上で、対話が
160
+ // 反応できるよう伝播は止めない。ここで既定動作の有無を見ないと、**一覧を畳む
161
+ // つもりの 1 打鍵で対話全体と入力内容が消える**
162
+ if (event.defaultPrevented) return
115
163
  event.preventDefault()
116
164
  closeRef.current()
117
165
  return
@@ -141,6 +189,197 @@ function useModalBehavior(isOpen: boolean, onClose: () => void, openerRef?: Reac
141
189
  return ref
142
190
  }
143
191
 
192
+ /**
193
+ * Turns catalog entries into picker options, marking the ones that are already linked.
194
+ * 候補一覧を選択肢へ変える処理 (すでに連携済みのものに印を付ける)。
195
+ *
196
+ * 連携済みを **隠さず、選べなくする** のは、運用者が探しているグループが「無い」のか
197
+ * 「既に繋がっている」のかを区別できるようにするためである。隠すと、見つからない理由が判らない。
198
+ *
199
+ * @param catalog The loader's catalog state. loader が渡した候補一覧の状態。
200
+ * @param linkedTo Upstream key to the local group already holding it. 上流キー → それを押さえているローカルグループ名。
201
+ * @param labels Resolved labels. 解決済みラベル。
202
+ * @returns Options for the picker. 選択欄に渡す選択肢。
203
+ */
204
+ function upstreamOptions(catalog: UserAdminCatalog, linkedTo: Map<string, string>, labels: UserAdminLabels): UpstreamOption[] {
205
+ if (catalog.kind !== "ready") return []
206
+ return catalog.groups.map((entry) => {
207
+ // ⚠️ 「連携済み」だけでは、どのローカルグループが押さえているのか判らず、探しようも
208
+ // 解除しようも無い。名指しできる情報は手元にあるので、名指しする。
209
+ // 突合は **安定 ID を第一キー、アドレスを第二キー** とする (サーバー側と同じ規則)。
210
+ // アドレスだけで見ると、上流で改名された直後は選べるように見えて、押すと拒否される
211
+ const holder = linkedTo.get(normalizeUpstreamKey(entry.externalId)) ?? linkedTo.get(normalizeUpstreamKey(entry.externalKey))
212
+ const linked = holder !== undefined
213
+ const members = entry.memberCount === undefined ? "" : fill(labels.groupsView.upstreamOptionMembers, { count: entry.memberCount })
214
+ return {
215
+ // ⚠️ 名前とアドレスの **両方を label に入れる**。選択欄が描くのも探すのも label だけで
216
+ // あり、別の項目へ入れた値は表示も検索もされない。同名のグループ (別部門の「営業部」等)
217
+ // を見分けられないまま選ばせるのが、この機能で最も避けたい取り違えである
218
+ label: fill(labels.directoryView.groupOptionTemplate, { name: entry.name, key: entry.externalKey }),
219
+ // ⚠️ 選択肢を区別するのは **安定 ID** である。アドレスは一意とは限らない (別名行、
220
+ // 改名の途中、別ドメインの同名) し、選んだ後に控えを引き当てるのもこの値である。
221
+ // アドレスを鍵にすると、2 件目を選んだのに 1 件目の名前が出る — 取り違えたまま
222
+ // 取り消せない委譲 (作成と連携) を押すことになる
223
+ value: entry.externalId,
224
+ note: linked ? fill(labels.groupsView.upstreamAlreadyLinked, { group: holder }) : members,
225
+ ...(linked ? { disabled: true } : {}),
226
+ entry,
227
+ }
228
+ })
229
+ }
230
+
231
+ /**
232
+ * States when the catalog was observed, loudly when it is older than the deployment allows.
233
+ * 候補一覧をいつ観測したかを示す表示。配備が許す鮮度を越えていれば強く出す。
234
+ *
235
+ * ⚠️ この一覧は上流を直に叩いた結果ではなく、資格情報を持つ同期ジョブが書いた **写し** である。
236
+ * 時刻を出さないと「上流の現在」と読まれ、既に消えたグループを選ばせる。逆に、作ったばかりの
237
+ * グループが出てこない理由も、時刻を見て初めて分かる。
238
+ *
239
+ * ⚠️ 陳腐化の判定は **loader が済ませている**。ここで `Date.now()` を読むと、時計が遅れている
240
+ * 端末では経過が負になり警告が消える (安全側の警告が利用者側の入力で無効化される)。加えて、
241
+ * 描画時に読むと開いたままの画面では判定が更新されず、水和のずれも生む。
242
+ */
243
+ function CatalogFreshness({ observedAt, isStale, labels }: { observedAt: string; isStale: boolean; labels: UserAdminLabels }): React.ReactNode {
244
+ return (
245
+ <p data-testid="catalog-freshness" data-stale={isStale ? "" : undefined} style={{ margin: "0.35rem 0 0 0", fontSize: "0.72rem", color: isStale ? "#92400e" : "#64748b" }}>
246
+ {fill(isStale ? labels.groupsView.catalogStale : labels.groupsView.catalogObservedAt, { at: formatDateTime(observedAt, labels) })}
247
+ </p>
248
+ )
249
+ }
250
+
251
+ /**
252
+ * Whether an action result carries a message worth describing the dialog with.
253
+ * 操作結果が、対話の説明として指す価値のある文言を持っているか。
254
+ *
255
+ * ⚠️ `aria-describedby` を常に張ると、誤りが無い間 **解決できない IDREF** を指し続ける。支援技術は
256
+ * 黙って無視するため実害は小さいが、「説明が在るのに読めない」状態を常設することになる。
257
+ *
258
+ * @param result The action result, if any. 操作結果 (無い場合あり)。
259
+ * @returns True when an error message exists. エラー文言が在れば true。
260
+ */
261
+ function hasError(result: unknown): boolean {
262
+ return typeof result === "object" && result !== null && (result as { ok?: unknown }).ok === false && typeof (result as { error?: unknown }).error === "string"
263
+ }
264
+
265
+ /**
266
+ * 一致箇所の装飾。
267
+ *
268
+ * ⚠️ **選択欄パッケージの内部クラス名を書かない。** `sb-highlight--*` はそのパッケージの公開 API
269
+ * ではなく、向こうが patch リリースで改名した瞬間に、こちらは何のエラーも出さずに装飾を失う。
270
+ * 自前の値で描き、印は自パッケージの `data-*` に持つ。
271
+ */
272
+ const matchStyle: Record<"exact" | "fuzzy", React.CSSProperties> = {
273
+ exact: { backgroundColor: "#d1fae5", color: "#064e3b", borderRadius: "2px", fontWeight: 600 },
274
+ fuzzy: { backgroundColor: "#fef3c7", color: "#78350f", borderRadius: "2px", fontWeight: 600 },
275
+ }
276
+
277
+ /**
278
+ * Indexes linked groups by both upstream keys. 連携済みグループを 2 つの上流キーで索引する処理。
279
+ *
280
+ * 安定 ID とアドレスの両方を引けるようにする。上流が改名すると、リンク行のアドレスは次の同期まで
281
+ * 古いままであり、アドレスだけの索引では「連携済み」を見落とす。
282
+ *
283
+ * @param groups Local groups, linked or not. ローカルグループ (連携の有無を問わない)。
284
+ * @returns Upstream key to the local group name holding it. 上流キー → 押さえているローカル名。
285
+ */
286
+ function upstreamIndex(groups: readonly AuthGroup[]): Map<string, string> {
287
+ const index = new Map<string, string>()
288
+ for (const group of groups) {
289
+ const link = group.externalLink
290
+ if (!link) continue
291
+ index.set(normalizeUpstreamKey(link.externalId), group.name)
292
+ index.set(normalizeUpstreamKey(link.externalKey), group.name)
293
+ }
294
+ return index
295
+ }
296
+
297
+ /** 選択欄 1 行分。`note` は選べない理由か規模を添える。 */
298
+ type UpstreamOption = { label: string; value: string; note: string; disabled?: boolean; entry: UserAdminCatalogGroup }
299
+
300
+ /**
301
+ * Renders one upstream option: the searchable label, plus why it cannot be picked.
302
+ * 上流の選択肢 1 行を描く処理 (検索対象のラベルと、選べない理由)。
303
+ *
304
+ * 既定の描画は `label` しか出さないため、`disabled` の理由が伝わらない。薄く見えるだけの行は、
305
+ * 読み込み中や壊れている行と区別できない。
306
+ */
307
+ function renderUpstreamOption(option: Option, context: RenderOptionContext): React.ReactNode {
308
+ const note = typeof option.note === "string" ? option.note : ""
309
+ return (
310
+ // ⚠️ 行は幅で切り詰められる。切り詰められるのは末尾、つまりアドレス — 同名のグループを
311
+ // 見分ける唯一の手がかりである。全文を `title` に持たせて、少なくとも確かめられるようにする
312
+ <span data-aqar-upstream-option title={note === "" ? option.label : `${option.label} — ${note}`} style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: "0.75rem", width: "100%", minWidth: 0 }}>
313
+ <span data-aqar-upstream-option-label style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
314
+ {/* 独自描画は既定の一致ハイライトを失う。同じ関数と同じ装飾クラスを使って復元する
315
+ (打った語がどこに当たったのか判らないと、絞り込みの手応えが消える) */}
316
+ {getHighlightSegments(option.label, context.query).map((segment, index) => (
317
+ // biome-ignore lint/suspicious/noArrayIndexKey: 分割位置そのものが同一性であり、他に安定した鍵が無い
318
+ <span key={index} data-aqar-match={segment.matchType === "none" ? undefined : segment.matchType} style={segment.matchType === "none" ? undefined : matchStyle[segment.matchType]}>
319
+ {segment.text}
320
+ </span>
321
+ ))}
322
+ </span>
323
+ {note === "" ? null : (
324
+ <span data-aqar-upstream-option-note style={{ flexShrink: 0, fontSize: "0.72rem", opacity: 0.75 }}>
325
+ {note}
326
+ </span>
327
+ )}
328
+ </span>
329
+ )
330
+ }
331
+
332
+ /**
333
+ * The texts `@aiquants/select-box` renders itself, taken from this package's labels.
334
+ * 選択欄が自前で描く文言を、このパッケージのラベルから与える処理。
335
+ *
336
+ * 渡さないと件数・空状態・クリア・開閉の読み上げが英語のまま出る — 和文の配備でそこだけ英語になる。
337
+ */
338
+ function pickerTexts(labels: UserAdminLabels): SelectBoxTexts {
339
+ const L = labels.directoryView
340
+ return {
341
+ noOptions: L.pickerNoOptions,
342
+ clear: L.pickerClear,
343
+ toggle: L.pickerToggle,
344
+ selection: (selected) => fill(L.pickerSelected, { value: selected.map((o) => o.label).join(", ") }),
345
+ status: (info) => fill(L.pickerAvailable, { count: info.count }),
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Builds a collator that cannot crash the view. 画面を落とさない照合器を作る処理。
351
+ *
352
+ * `Intl.Collator` は BCP 47 として読めないタグに `RangeError` を投げる。並べ替えは表示だけの
353
+ * 関心事なのに、投げられると一覧そのものが描けなくなる。読めないタグは環境既定に倒す。
354
+ *
355
+ * @param locale Requested locale tag. 要求されたロケールタグ。
356
+ * @returns A comparison function for display strings. 表示文字列用の比較関数。
357
+ */
358
+ function collatorFor(locale: string): (a: string, b: string) => number {
359
+ try {
360
+ return new Intl.Collator(locale).compare
361
+ } catch {
362
+ return new Intl.Collator().compare
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Derives a local group key from an upstream address. 上流アドレスからローカルのグループキーを導く処理。
368
+ *
369
+ * アドレスのローカル部だけを使い、鍵に使えない文字を落とす。運用者が手で打ち直す手間を省くための
370
+ * **初期値** であり、確定値ではない (画面上で編集できる)。
371
+ *
372
+ * @param externalKey Upstream address. 上流のアドレス。
373
+ * @returns A usable group key. グループキーとして使える文字列。
374
+ */
375
+ function groupKeyFromUpstream(externalKey: string): string {
376
+ const local = externalKey.split("@")[0] ?? externalKey
377
+ return local
378
+ .toLowerCase()
379
+ .replace(/[^a-z0-9._-]+/g, "-")
380
+ .replace(/^-+|-+$/g, "")
381
+ }
382
+
144
383
  export function UsersView() {
145
384
  const data = useLoaderData() as UserAdminLoaderData
146
385
  const fetcher = useFetcher()
@@ -158,6 +397,12 @@ export function UsersView() {
158
397
  const openModal = (next: { kind: "create" } | { kind: "edit"; user: AuthUser }, event: React.MouseEvent<HTMLElement>) => {
159
398
  openerRef.current = event.currentTarget
160
399
  resetResult()
400
+ // ⚠️ 前回の下書きを残すと、別人を追加するつもりで開いた欄に前の人の Email が入っている。
401
+ // 見えている表示名だけ書き換えて送ると、**別人のサインイン identity を持つ利用者** ができる
402
+ if (next.kind === "create") {
403
+ setNewDisplayName("")
404
+ setNewEmail("")
405
+ }
161
406
  setModal(next)
162
407
  }
163
408
 
@@ -167,11 +412,35 @@ export function UsersView() {
167
412
  const editUserRef = useModalBehavior(editingUser !== null, () => setModal(null), openerRef)
168
413
 
169
414
  const busy = fetcher.state !== "idle"
170
- const filteredUsers = data.users.filter((u) => u.displayName.toLowerCase().includes(searchQuery.toLowerCase()) || u.email.toLowerCase().includes(searchQuery.toLowerCase()))
415
+ // 並べ替えは表示だけの関心事なので状態は画面に持つ。URL へ載せると共有できるが、
416
+ // 「同じ URL を開いたのに並びが違う」という取り違えを防ぐには loader 側の合意が要る
417
+ const [sort, setSort] = useState<{ column: "id" | "displayName" | "email" | "isActive"; ascending: boolean }>({ column: "displayName", ascending: true })
418
+ const compareText = collatorFor(labels.locale)
419
+ // メールクライアントやチャットから貼った値はほぼ必ず末尾に空白を伴い、IME は全角空白を作る。
420
+ // trim しないと、存在する利用者が「登録が無い」ように見える
421
+ const userNeedle = searchQuery.trim().toLowerCase()
422
+ const filteredUsers = data.users
423
+ .filter((u) => u.displayName.toLowerCase().includes(userNeedle) || u.email.toLowerCase().includes(userNeedle))
424
+ .slice()
425
+ .sort((a, b) => {
426
+ const direction = sort.ascending ? 1 : -1
427
+ if (sort.column === "id") return (a.id - b.id) * direction
428
+ if (sort.column === "isActive") return (Number(a.isActive) - Number(b.isActive)) * direction
429
+ // 表示名と Email は言語に依存する。ロケールを固定して、環境ごとに並びが変わるのを防ぐ
430
+ return compareText(a[sort.column], b[sort.column]) * direction
431
+ })
432
+
433
+ /** 同じ列を押したら向きだけ変える。別の列なら昇順から始める。 */
434
+ const toggleSort = (column: typeof sort.column) => {
435
+ setSort((current) => (current.column === column ? { column, ascending: !current.ascending } : { column, ascending: true }))
436
+ }
171
437
 
172
438
  const handleToggleActive = (user: AuthUser) => {
439
+ // ⚠️ `aria-disabled` は焦点を残すために `disabled` の代わりに使っている。押せてしまう
440
+ // ぶん、実際に断るのはここである
441
+ if (busy) return
173
442
  // 無効化はサインインを止める。有効化は元に戻すだけなので確認しない
174
- if (user.isActive && !window.confirm(labels.usersView.confirmDeactivate)) return
443
+ if (user.isActive && !window.confirm(fill(labels.usersView.confirmDeactivate, { user: user.displayName }))) return
175
444
  submitThen({ intent: "toggleUserActive", userId: String(user.id), isActive: String(!user.isActive) })
176
445
  }
177
446
 
@@ -218,15 +487,46 @@ export function UsersView() {
218
487
  </button>
219
488
  </div>
220
489
 
490
+ {/* 絞り込みの結果は視覚的にしか変わらない。読み上げへ届けるには live region が要る —
491
+ 無いと、打ち込むたびに何件残ったのか判らないまま表全体を辿ることになる */}
492
+ <p data-testid="users-summary" role="status" aria-live="polite" style={{ margin: 0, fontSize: "0.78rem", color: "#475569" }}>
493
+ {fill(labels.usersView.listSummary, { shown: filteredUsers.length, total: data.users.length })}
494
+ </p>
495
+
221
496
  <div style={{ backgroundColor: "#ffffff", borderRadius: "8px", border: "1px solid #e2e8f0", overflow: "hidden" }}>
222
497
  <table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.85rem", textAlign: "left" }}>
223
498
  <thead>
224
499
  <tr style={{ backgroundColor: "#f8fafc", borderBottom: "1px solid #e2e8f0", color: "#475569" }}>
225
- <th style={{ padding: "10px 14px" }}>{labels.common.id}</th>
226
- <th style={{ padding: "10px 14px" }}>{labels.usersView.displayName}</th>
227
- <th style={{ padding: "10px 14px" }}>{labels.usersView.email}</th>
228
- <th style={{ padding: "10px 14px" }}>{labels.common.status}</th>
229
- <th style={{ padding: "10px 14px", textAlign: "right" }}>{labels.common.actions}</th>
500
+ {(
501
+ [
502
+ ["id", labels.common.id],
503
+ ["displayName", labels.usersView.displayName],
504
+ ["email", labels.usersView.email],
505
+ ["isActive", labels.common.status],
506
+ ] as const
507
+ ).map(([column, heading]) => (
508
+ <th key={column} scope="col" style={{ padding: 0 }} aria-sort={sort.column === column ? (sort.ascending ? "ascending" : "descending") : "none"}>
509
+ <button
510
+ type="button"
511
+ data-testid={`users-sort-${column}`}
512
+ onClick={() => toggleSort(column)}
513
+ // 向きを名前に含める。含めないと、押しても押し返しても同じ
514
+ // 「Sort by 名前」としか読まれず、今どちらなのか判らない
515
+ aria-label={fill(sort.column === column ? (sort.ascending ? labels.usersView.sortedAscending : labels.usersView.sortedDescending) : labels.usersView.sortBy, { column: heading })}
516
+ style={{ width: "100%", padding: "10px 14px", textAlign: "left", background: "transparent", border: "none", cursor: "pointer", font: "inherit", color: "inherit", fontWeight: sort.column === column ? 700 : 500 }}>
517
+ {heading}
518
+ {/* 向きは記号だけに頼らない。読み上げは aria-sort が担う */}
519
+ {/* 使っていない列に ▲ を薄く出すと「昇順で並んでいる」と読めてしまう。
520
+ 並べ替えていない列は向きを持たない記号を出す */}
521
+ <span aria-hidden="true" style={{ marginLeft: "4px", opacity: sort.column === column ? 1 : 0.35 }}>
522
+ {sort.column === column ? (sort.ascending ? "▲" : "▼") : "⇅"}
523
+ </span>
524
+ </button>
525
+ </th>
526
+ ))}
527
+ <th scope="col" style={{ padding: "10px 14px", textAlign: "right" }}>
528
+ {labels.common.actions}
529
+ </th>
230
530
  </tr>
231
531
  </thead>
232
532
  <tbody>
@@ -252,8 +552,7 @@ export function UsersView() {
252
552
  <button
253
553
  type="button"
254
554
  onClick={() => handleToggleActive(u)}
255
- disabled={busy}
256
- aria-busy={busy}
555
+ {...rowActionProps(busy)}
257
556
  aria-label={fill(u.isActive ? labels.usersView.deactivateAria : labels.usersView.activateAria, { user: u.displayName })}
258
557
  data-testid={`toggle-user-btn-${u.id}`}
259
558
  style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", fontSize: "0.78rem", cursor: busy ? "default" : "pointer", color: u.isActive ? "#b91c1c" : "#15803d" }}>
@@ -265,7 +564,9 @@ export function UsersView() {
265
564
  {filteredUsers.length === 0 && (
266
565
  <tr>
267
566
  <td colSpan={5} style={{ padding: "2rem", textAlign: "center", color: "#475569" }}>
268
- {labels.usersView.empty}
567
+ {/* 「検索で 0 件」と「そもそも登録が無い」は別の事態である。同じ文言に
568
+ すると、新規テナントを開いた運用者は一覧が壊れていると読む */}
569
+ {userNeedle === "" ? labels.usersView.empty : labels.usersView.emptyFiltered}
269
570
  </td>
270
571
  </tr>
271
572
  )}
@@ -282,7 +583,7 @@ export function UsersView() {
282
583
  role="dialog"
283
584
  aria-modal="true"
284
585
  aria-labelledby="create-user-title"
285
- aria-describedby="create-user-error"
586
+ {...(hasError(actionResult) ? { "aria-describedby": "create-user-error" } : {})}
286
587
  data-testid="user-create-modal"
287
588
  style={{ backgroundColor: "#ffffff", padding: "1.5rem", borderRadius: "8px", width: "360px", boxShadow: "0 10px 25px rgba(0,0,0,0.15)" }}>
288
589
  <h3 id="create-user-title" style={{ margin: "0 0 1rem 0", fontSize: "1.1rem" }}>
@@ -338,7 +639,7 @@ export function UsersView() {
338
639
  role="dialog"
339
640
  aria-modal="true"
340
641
  aria-labelledby="edit-user-title"
341
- aria-describedby="edit-user-error"
642
+ {...(hasError(actionResult) ? { "aria-describedby": "edit-user-error" } : {})}
342
643
  data-testid="user-edit-modal"
343
644
  style={{ backgroundColor: "#ffffff", padding: "1.5rem", borderRadius: "8px", width: "360px", boxShadow: "0 10px 25px rgba(0,0,0,0.15)" }}>
344
645
  <h3 id="edit-user-title" style={{ margin: "0 0 1rem 0", fontSize: "1.1rem" }}>
@@ -403,10 +704,13 @@ export function GroupsView() {
403
704
  const editingGroup = modal?.kind === "edit" ? modal.group : null
404
705
  const isCreateOpen = modal?.kind === "create"
405
706
  const openerRef = useRef<HTMLElement | null>(null)
406
- /** ダイアログを開く。直前の操作の結果を持ち越さない (別の操作の拒否が説明文になる)。 */
707
+ /** ダイアログを開く。直前の操作の結果と入力を持ち越さない。 */
407
708
  const openModal = (next: { kind: "create" } | { kind: "edit"; group: AuthGroup }, event: React.MouseEvent<HTMLElement>) => {
408
709
  openerRef.current = event.currentTarget
409
710
  resetResult()
711
+ // 前回の入力を残すと、上流から作った直後に開いた作成欄が別グループの名前で埋まっている。
712
+ // 「連携済みなのに、もう 1 つ作ろうとしている」ように見え、取り違えたまま送られる
713
+ if (next.kind === "create") resetCreateFields()
410
714
  setModal(next)
411
715
  }
412
716
 
@@ -414,22 +718,113 @@ export function GroupsView() {
414
718
  const [newGroupKey, setNewGroupKey] = useState("")
415
719
  const [newName, setNewName] = useState("")
416
720
  const [newDescription, setNewDescription] = useState("")
721
+ // 上流から作る経路は、候補一覧が引けているときだけ選べる
722
+ const [createMode, setCreateMode] = useState<"local" | "upstream">("local")
723
+ /**
724
+ * 焦点が当たっている作成経路。
725
+ *
726
+ * ⚠️ ラジオ群の実体は視覚的に隠してある (装飾はラベル側の押しボタン風の見た目)。隠した入力へ
727
+ * 焦点が来ると、ブラウザは 1px の箱に焦点環を描き、操作者からは「どこにも焦点が無い」ように
728
+ * 見える。インラインの style では `:focus-within` を書けないため、焦点の在処を状態で持って
729
+ * ラベルへ環を描く
730
+ */
731
+ const [focusedMode, setFocusedMode] = useState<"local" | "upstream" | null>(null)
732
+ const [upstream, setUpstream] = useState<UserAdminCatalogGroup | null>(null)
733
+ const [membershipMode, setMembershipMode] = useState<AuthDirectoryMembershipMode>("direct")
734
+ /**
735
+ * 上流から埋めた 3 欄の控え。ローカルへ戻すときに「こちらが埋めた分だけ」を消すために持つ。
736
+ * 一律に消すと、先に手で打った内容まで失う
737
+ */
738
+ const prefilled = useRef<{ key: string; name: string; description: string } | null>(null)
739
+ // ⚠️ 候補の作り直しは描画ごとに行わない。新しい配列を毎回渡すと、選択欄は同一性で
740
+ // 記憶しているため、**無関係な欄の 1 打鍵ごとに候補全件を再正規化し、あいまい検索の
741
+ // 索引まで組み直す**。候補 4000 件で 1 打鍵 22ms を実測した (実ブラウザではさらに重い)
742
+ // ⚠️ 所属件数はグループごとに数え直さない。カード 1 枚ごとに所属配列を全走査すると
743
+ // O(グループ数 × 所属数) になり、検索欄の 1 打鍵ごとにそれを繰り返す。1 回だけ畳んでおく
744
+ const memberCountByGroup = useMemo(() => {
745
+ const counts = new Map<number, number>()
746
+ for (const m of data.groupMembers) counts.set(m.groupId, (counts.get(m.groupId) ?? 0) + 1)
747
+ return counts
748
+ }, [data.groupMembers])
749
+ const linkedTo = useMemo(() => upstreamIndex(data.groups), [data.groups])
750
+ const options = useMemo(() => upstreamOptions(data.catalog, linkedTo, labels), [data.catalog, linkedTo, labels])
751
+ // ⚠️ 判定に使うのは **選べる候補の数** である。行数で判定すると、全件が連携済みの配備で
752
+ // 「すべて選べない選択欄」と「押せない作成ボタン」を出し、hint は「まず候補を選べ」と
753
+ // 言う — 従いようのない指示になる
754
+ const selectableUpstreamCount = useMemo(() => options.filter((o) => o.disabled !== true).length, [options])
755
+ // 候補が引けても、この配備がどのプロバイダとして連携するかが決まっていなければ連携は作れない。
756
+ // 送れば `provider: ""` の行が残り、同期ジョブは「別プロバイダ」として恒久的に無視する
757
+ // ⚠️ 候補が 0 件の一覧で経路を出すと、選べない選択欄と押せない作成ボタンという終端へ導く。
758
+ // 「読めたが 1 件も無い」は上流側の話であり、この面で解決できることは何も無い
759
+ const upstreamPickable = data.catalog.kind === "ready" && data.catalog.groups.length > 0 && data.directoryStatus.provider !== ""
417
760
  const createGroupRef = useModalBehavior(isCreateOpen, () => setModal(null), openerRef)
418
761
  const editGroupRef = useModalBehavior(editingGroup !== null, () => setModal(null), openerRef)
419
762
 
420
- const filteredGroups = data.groups.filter((g) => g.name.toLowerCase().includes(searchQuery.toLowerCase()) || g.groupKey.toLowerCase().includes(searchQuery.toLowerCase()))
763
+ const groupNeedle = searchQuery.trim().toLowerCase()
764
+ const filteredGroups = data.groups.filter((g) => g.name.toLowerCase().includes(groupNeedle) || g.groupKey.toLowerCase().includes(groupNeedle))
765
+
766
+ /** 作成欄をすべて初期状態へ戻す。 */
767
+ const resetCreateFields = () => {
768
+ setNewGroupKey("")
769
+ setNewName("")
770
+ setNewDescription("")
771
+ setCreateMode("local")
772
+ setUpstream(null)
773
+ setMembershipMode("direct")
774
+ prefilled.current = null
775
+ }
421
776
 
422
777
  const handleCreateGroup = (e: React.FormEvent) => {
423
778
  e.preventDefault()
424
779
  if (!(newGroupKey && newName)) return
780
+ if (createMode === "upstream" && upstream === null) return
781
+ // 連携付きで作ると、名前・説明・メンバー・削除のすべてが上流の管理下へ移る。取り消すには
782
+ // 連携解除という別の操作が要るため、押した先が戻れない操作であることを先に伝える
783
+ if (createMode === "upstream" && upstream !== null && !window.confirm(fill(labels.groupsView.confirmCreateLinked, { group: newName, upstream: upstream.externalKey }))) return
425
784
  // 送信した値と今の値が違うなら、往復の間に打ち込まれたものである。消してはならない
426
785
  const sent = { key: newGroupKey, name: newName, description: newDescription }
427
- submitThen({ intent: "createGroup", groupKey: sent.key, name: sent.name, description: sent.description }, () => {
786
+ const clear = () => {
428
787
  setNewGroupKey((current) => (current === sent.key ? "" : current))
429
788
  setNewName((current) => (current === sent.name ? "" : current))
430
789
  setNewDescription((current) => (current === sent.description ? "" : current))
790
+ setUpstream(null)
431
791
  setModal(null)
432
- })
792
+ }
793
+ if (createMode === "upstream" && upstream !== null) {
794
+ // 作成と連携は 1 つの intent で行う。2 回に分けると、片方だけ成功した状態が生まれる
795
+ submitThen({ intent: "createLinkedGroup", groupKey: sent.key, name: sent.name, description: sent.description, provider: data.directoryStatus.provider, externalKey: upstream.externalKey, membershipMode }, clear)
796
+ return
797
+ }
798
+ submitThen({ intent: "createGroup", groupKey: sent.key, name: sent.name, description: sent.description }, clear)
799
+ }
800
+
801
+ /** 上流グループを選んだら、名前と鍵の初期値を埋める (打ち直しの手間を省く)。 */
802
+ const chooseUpstream = (entry: UserAdminCatalogGroup | null) => {
803
+ setUpstream(entry)
804
+ if (!entry) return
805
+ const filled = { key: groupKeyFromUpstream(entry.externalKey), name: entry.name, description: entry.description ?? "" }
806
+ setNewName(filled.name)
807
+ setNewGroupKey(filled.key)
808
+ setNewDescription(filled.description)
809
+ prefilled.current = filled
810
+ }
811
+
812
+ /**
813
+ * 作成の経路を切り替える。ローカルへ戻すときは、上流から埋めた欄だけを空へ戻す。
814
+ *
815
+ * 残すと、ローカル作成のはずが上流グループの名前と説明で作られる。一律に消すと、先に
816
+ * 手で打った内容まで失う
817
+ */
818
+ const switchCreateMode = (mode: "local" | "upstream") => {
819
+ setCreateMode(mode)
820
+ if (mode === "upstream") return
821
+ setUpstream(null)
822
+ const filled = prefilled.current
823
+ if (!filled) return
824
+ setNewGroupKey((current) => (current === filled.key ? "" : current))
825
+ setNewName((current) => (current === filled.name ? "" : current))
826
+ setNewDescription((current) => (current === filled.description ? "" : current))
827
+ prefilled.current = null
433
828
  }
434
829
 
435
830
  const handleUpdateGroup = (e: React.FormEvent) => {
@@ -438,9 +833,15 @@ export function GroupsView() {
438
833
  submitThen({ intent: "updateGroup", groupId: String(editingGroup.id), name: editingGroup.name, description: editingGroup.description ?? "" }, () => setModal(null))
439
834
  }
440
835
 
441
- const handleDeleteGroup = (groupId: number) => {
442
- if (!window.confirm(labels.groupsView.confirmDelete)) return
443
- submitThen({ intent: "deleteGroup", groupId: String(groupId) })
836
+ const handleDeleteGroup = (group: AuthGroup) => {
837
+ // ⚠️ `aria-disabled` は焦点を残すために `disabled` の代わりに使っている。押せてしまう
838
+ // ぶん、実際に断るのはここである
839
+ if (busy) return
840
+ // ⚠️ 名前も失うものも述べる。この面で最も破壊的な操作でありながら、確認が最も薄かった。
841
+ // 一覧の中から誤って隣のカードを押しても、対話の中に気付ける手がかりが無かった
842
+ const memberCount = data.groupMembers.filter((m) => m.groupId === group.id).length
843
+ if (!window.confirm(fill(labels.groupsView.confirmDelete, { group: group.name, count: memberCount }))) return
844
+ submitThen({ intent: "deleteGroup", groupId: String(group.id) })
444
845
  }
445
846
 
446
847
  return (
@@ -468,7 +869,7 @@ export function GroupsView() {
468
869
 
469
870
  <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: "1rem" }}>
470
871
  {filteredGroups.map((g) => {
471
- const memberCount = data.groupMembers.filter((m) => m.groupId === g.id).length
872
+ const memberCount = memberCountByGroup.get(g.id) ?? 0
472
873
  return (
473
874
  <div
474
875
  key={g.id}
@@ -477,7 +878,7 @@ export function GroupsView() {
477
878
  data-group-readonly={g.readOnly ? "true" : "false"}>
478
879
  <div>
479
880
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "0.5rem" }}>
480
- <h4 style={{ margin: 0, fontSize: "1rem", color: "#0f172a" }}>{g.name}</h4>
881
+ <h3 style={{ margin: 0, fontSize: "1rem", color: "#0f172a" }}>{g.name}</h3>
481
882
  <span style={{ fontSize: "0.72rem", backgroundColor: "#e0e7ff", color: "#4338ca", padding: "2px 6px", borderRadius: "4px", fontFamily: "monospace" }}>{g.groupKey}</span>
482
883
  </div>
483
884
  <p style={{ margin: "0 0 0.8rem 0", fontSize: "0.82rem", color: "#64748b", minHeight: "2.4em" }}>{g.description || labels.groupsView.noDescription}</p>
@@ -495,9 +896,9 @@ export function GroupsView() {
495
896
  )}
496
897
  </div>
497
898
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", borderTop: "1px solid #f1f5f9", paddingTop: "0.6rem" }}>
498
- <span style={{ fontSize: "0.78rem", color: "#475569" }} data-testid={`group-member-count-${g.id}`}>
899
+ <Link to={`/users/group_members?group=${g.id}`} data-testid={`group-open-members-${g.id}`} aria-label={fill(labels.groupsView.openMembersAria, { group: g.name })} style={{ fontSize: "0.78rem", color: "#1d4ed8", textDecoration: "none" }}>
499
900
  {fill(labels.groupsView.memberCountTemplate, { count: memberCount })}
500
- </span>
901
+ </Link>
501
902
  {/* 上流が所有する行では操作を **描かない**。無効化したボタンを出すと、
502
903
  押せる操作と見分けがつかず、押せない理由も伝わらない */}
503
904
  {g.readOnly ? (
@@ -509,8 +910,7 @@ export function GroupsView() {
509
910
  <button
510
911
  type="button"
511
912
  onClick={(event) => openModal({ kind: "edit", group: g }, event)}
512
- disabled={busy}
513
- aria-busy={busy}
913
+ {...rowActionProps(busy)}
514
914
  aria-label={fill(labels.groupsView.editGroupAria, { group: g.name })}
515
915
  data-testid={`edit-group-btn-${g.id}`}
516
916
  style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", fontSize: "0.75rem", cursor: "pointer", marginRight: "4px" }}>
@@ -518,9 +918,8 @@ export function GroupsView() {
518
918
  </button>
519
919
  <button
520
920
  type="button"
521
- onClick={() => handleDeleteGroup(g.id)}
522
- disabled={busy}
523
- aria-busy={busy}
921
+ onClick={() => handleDeleteGroup(g)}
922
+ {...rowActionProps(busy)}
524
923
  aria-label={fill(labels.groupsView.deleteGroupAria, { group: g.name })}
525
924
  data-testid={`delete-group-btn-${g.id}`}
526
925
  style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #fee2e2", backgroundColor: "#fff5f5", color: "#b91c1c", fontSize: "0.75rem", cursor: "pointer" }}>
@@ -548,13 +947,131 @@ export function GroupsView() {
548
947
  role="dialog"
549
948
  aria-modal="true"
550
949
  aria-labelledby="create-group-title"
551
- aria-describedby="create-group-error"
950
+ {...(hasError(actionResult) ? { "aria-describedby": "create-group-error" } : {})}
552
951
  data-testid="group-create-modal"
553
952
  style={{ backgroundColor: "#ffffff", padding: "1.5rem", borderRadius: "8px", width: "360px", boxShadow: "0 10px 25px rgba(0,0,0,0.15)" }}>
554
953
  <h3 id="create-group-title" style={{ margin: "0 0 1rem 0", fontSize: "1.1rem" }}>
555
954
  {labels.groupsView.addGroup}
556
955
  </h3>
557
956
  <ActionError result={actionResult} id="create-group-error" />
957
+
958
+ {/* 上流から作る経路は候補一覧が在るときだけ提示する。出しておいて押せない
959
+ 切替は、機能が壊れているのと見分けが付かない */}
960
+ {upstreamPickable && (
961
+ <fieldset data-testid="create-group-mode" style={{ display: "flex", gap: "0.25rem", marginBottom: "1rem", border: "none", padding: 0, margin: "0 0 1rem 0" }}>
962
+ {/* ⚠️ 本物のラジオ群である。同じ `name` を持つ入力にはブラウザが矢印キー移動と
963
+ 「選択中の 1 つだけが Tab の停止点」を無償で与える。押しボタンに
964
+ `role="radio"` を被せると、その両方を手で書き直すことになる */}
965
+ <legend style={visuallyHidden}>{labels.groupsView.createModeLabel}</legend>
966
+ {(["local", "upstream"] as const).map((mode) => (
967
+ <label
968
+ key={mode}
969
+ data-aqar-create-mode={mode}
970
+ data-selected={createMode === mode ? "" : undefined}
971
+ data-focused={focusedMode === mode ? "" : undefined}
972
+ style={{
973
+ outline: focusedMode === mode ? "2px solid #4338ca" : undefined,
974
+ outlineOffset: "2px",
975
+ flex: 1,
976
+ padding: "0.4rem 0.6rem",
977
+ fontSize: "0.8rem",
978
+ borderRadius: "6px",
979
+ cursor: "pointer",
980
+ textAlign: "center",
981
+ border: createMode === mode ? "1px solid #4338ca" : "1px solid #cbd5e1",
982
+ backgroundColor: createMode === mode ? "#eef2ff" : "#ffffff",
983
+ color: createMode === mode ? "#3730a3" : "#475569",
984
+ fontWeight: createMode === mode ? 700 : 500,
985
+ }}>
986
+ <input
987
+ type="radio"
988
+ name="create-group-mode"
989
+ value={mode}
990
+ checked={createMode === mode}
991
+ onChange={() => switchCreateMode(mode)}
992
+ data-testid={`create-group-mode-${mode}`}
993
+ data-aqar-visually-hidden
994
+ onFocus={() => setFocusedMode(mode)}
995
+ onBlur={() => setFocusedMode((current) => (current === mode ? null : current))}
996
+ style={visuallyHidden}
997
+ />
998
+ {mode === "local" ? labels.groupsView.createModeLocal : labels.groupsView.createModeUpstream}
999
+ </label>
1000
+ ))}
1001
+ </fieldset>
1002
+ )}
1003
+
1004
+ {data.catalog.kind === "ready" && data.catalog.groups.length === 0 && (
1005
+ <p data-testid="create-group-catalog-empty" role="note" style={{ margin: "0 0 1rem 0", padding: "0.5rem 0.7rem", borderRadius: "6px", backgroundColor: "#f8fafc", border: "1px solid #e2e8f0", color: "#475569", fontSize: "0.78rem" }}>
1006
+ {fill(labels.groupsView.catalogEmpty, { at: formatDateTime(data.catalog.observedAt ?? "", labels) })}
1007
+ </p>
1008
+ )}
1009
+
1010
+ {data.catalog.kind === "ready" && data.catalog.groups.length > 0 && data.directoryStatus.provider === "" && (
1011
+ <p data-testid="create-group-provider-unset" role="note" style={{ margin: "0 0 1rem 0", padding: "0.5rem 0.7rem", borderRadius: "6px", backgroundColor: "#fffbeb", border: "1px solid #fde68a", color: "#92400e", fontSize: "0.78rem" }}>
1012
+ {labels.groupsView.providerUnset}
1013
+ </p>
1014
+ )}
1015
+
1016
+ {data.catalog.kind === "unavailable" && (
1017
+ <p data-testid="create-group-catalog-unavailable" role="note" style={{ margin: "0 0 1rem 0", padding: "0.5rem 0.7rem", borderRadius: "6px", backgroundColor: "#fffbeb", border: "1px solid #fde68a", color: "#92400e", fontSize: "0.78rem" }}>
1018
+ {/* ⚠️ この対話には上流アドレスの欄が無い (一覧が引けないと経路ごと消える)。
1019
+ 「手入力してください」と案内すると、存在しない欄を探させることになる */}
1020
+ {fill(labels.groupsView.catalogUnavailableHere, { reason: labels.groupsView.catalogFailures[data.catalog.reason] })}
1021
+ </p>
1022
+ )}
1023
+
1024
+ {createMode === "upstream" && upstreamPickable && data.catalog.kind === "ready" && (
1025
+ <div style={{ marginBottom: "1rem" }}>
1026
+ <p data-testid="create-group-upstream-notice" role="note" style={{ margin: "0 0 0.5rem 0", fontSize: "0.78rem", color: "#5b21b6" }}>
1027
+ {labels.groupsView.createUpstreamNotice}
1028
+ </p>
1029
+ <div id="create-group-upstream-label" style={fieldLabel}>
1030
+ {labels.groupsView.pickUpstream}
1031
+ </div>
1032
+ {/* ⚠️ 選択欄は行を選んでいない Enter を「フォームへ渡す」設計であり、
1033
+ 絞り込みを打った直後の Enter が **前回選んだ相手のまま** 送信になる。
1034
+ 送信先は取り消せない委譲であるため、ここで暗黙送信だけを止める */}
1035
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: 子の暗黙送信を止めるためだけの捕捉であり、対話要素ではない */}
1036
+ <div
1037
+ data-testid="create-group-upstream-picker"
1038
+ onKeyDown={(event) => {
1039
+ // ⚠️ 変換中の Enter には触れない。日本語 IME の確定は
1040
+ // `keydown{key:"Enter", keyCode:229, isComposing:true}` として届き、
1041
+ // ここで既定動作を止めると変換の確定そのものを潰し得る。
1042
+ // 変換中はそもそも暗黙の送信は起きないので、止める意味も無い
1043
+ if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return
1044
+ if (event.key === "Enter") event.preventDefault()
1045
+ }}>
1046
+ <SelectBox
1047
+ options={options}
1048
+ value={upstream === null ? null : (options.find((o) => o.value === upstream.externalId) ?? null)}
1049
+ onChange={(picked: Option | Option[] | null) => chooseUpstream(picked === null || Array.isArray(picked) ? null : ((picked as { entry?: UserAdminCatalogGroup }).entry ?? null))}
1050
+ placeholder={labels.groupsView.pickUpstreamPlaceholder}
1051
+ ariaLabelledBy="create-group-upstream-label"
1052
+ renderOption={renderUpstreamOption}
1053
+ texts={pickerTexts(labels)}
1054
+ clearable
1055
+ />
1056
+ </div>
1057
+ <CatalogFreshness observedAt={data.catalog.observedAt} isStale={data.catalog.isStale} labels={labels} />
1058
+ <div style={{ marginTop: "0.6rem" }}>
1059
+ <label htmlFor="create-group-membership-mode" style={fieldLabel}>
1060
+ {labels.directoryView.membershipMode}
1061
+ </label>
1062
+ <select
1063
+ id="create-group-membership-mode"
1064
+ data-testid="create-group-membership-mode"
1065
+ value={membershipMode}
1066
+ onChange={(e) => setMembershipMode(e.target.value as AuthDirectoryMembershipMode)}
1067
+ style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box" }}>
1068
+ <option value="direct">{labels.directoryView.directMode}</option>
1069
+ <option value="transitive">{labels.directoryView.transitiveMode}</option>
1070
+ </select>
1071
+ </div>
1072
+ </div>
1073
+ )}
1074
+
558
1075
  <div style={{ marginBottom: "1rem" }}>
559
1076
  <label htmlFor="create-group-key-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
560
1077
  {labels.groupsView.groupKey}
@@ -597,11 +1114,37 @@ export function GroupsView() {
597
1114
  style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box", height: "60px" }}
598
1115
  />
599
1116
  </div>
1117
+ {/* ⚠️ 押せない理由を `title` だけに置かない。無効化されたボタンは焦点を持てず、
1118
+ 読み上げの要素移動でも飛ばされ、Safari では tooltip も出ない — つまり
1119
+ キーボード・読み上げ・多くのマウス操作者のいずれにも届かない */}
1120
+ {createMode === "upstream" && upstream === null && (
1121
+ <p id="create-group-submit-hint" data-testid="create-group-submit-hint" style={{ margin: "0 0 0.6rem 0", fontSize: "0.78rem", color: "#475569", textAlign: "right" }}>
1122
+ {/* ⚠️ 選べる候補が 1 件も無いときに「先に選べ」と言ってはならない。
1123
+ 従いようのない指示であり、操作者は自分の見落としを探し続ける。
1124
+ 候補は隠さない (どれが既に繋がっているのかを見せる価値は残る) */}
1125
+ {selectableUpstreamCount > 0 ? labels.groupsView.pickUpstreamFirst : labels.groupsView.allUpstreamLinked}
1126
+ </p>
1127
+ )}
600
1128
  <div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
601
1129
  <button type="button" onClick={() => setModal(null)} data-testid="cancel-create-group" style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
602
1130
  {labels.common.cancel}
603
1131
  </button>
604
- <button type="submit" data-testid="submit-create-group" disabled={busy} aria-busy={busy} style={{ padding: "6px 12px", borderRadius: "4px", backgroundColor: "#4338ca", color: "#ffffff", border: "none", cursor: "pointer" }}>
1132
+ <button
1133
+ type="submit"
1134
+ data-testid="submit-create-group"
1135
+ // 上流モードで候補が空のまま押せると、送信は黙って捨てられる。押せない理由を
1136
+ // 見せる方が、無反応なボタンより短く伝わる
1137
+ disabled={busy || (createMode === "upstream" && upstream === null)}
1138
+ aria-busy={busy}
1139
+ {...(createMode === "upstream" && upstream === null ? { "aria-describedby": "create-group-submit-hint" } : {})}
1140
+ style={{
1141
+ padding: "6px 12px",
1142
+ borderRadius: "4px",
1143
+ backgroundColor: busy || (createMode === "upstream" && upstream === null) ? "#a5b4fc" : "#4338ca",
1144
+ color: "#ffffff",
1145
+ border: "none",
1146
+ cursor: busy || (createMode === "upstream" && upstream === null) ? "not-allowed" : "pointer",
1147
+ }}>
605
1148
  {labels.common.create}
606
1149
  </button>
607
1150
  </div>
@@ -618,7 +1161,7 @@ export function GroupsView() {
618
1161
  role="dialog"
619
1162
  aria-modal="true"
620
1163
  aria-labelledby="edit-group-title"
621
- aria-describedby="edit-group-error"
1164
+ {...(hasError(actionResult) ? { "aria-describedby": "edit-group-error" } : {})}
622
1165
  data-testid="group-edit-modal"
623
1166
  style={{ backgroundColor: "#ffffff", padding: "1.5rem", borderRadius: "8px", width: "360px", boxShadow: "0 10px 25px rgba(0,0,0,0.15)" }}>
624
1167
  <h3 id="edit-group-title" style={{ margin: "0 0 1rem 0", fontSize: "1.1rem" }}>
@@ -677,17 +1220,80 @@ export function GroupMembersView() {
677
1220
  const busy = fetcher.state !== "idle"
678
1221
  const labels = data.labels ?? defaultUserAdminLabels
679
1222
 
680
- const [selectedGroupId, setSelectedGroupId] = useState<number>(data.groups[0]?.id ?? 0)
1223
+ // 遷移元がグループを指定していればそれを選ぶ (グループ一覧から 1 クリックで来られるように)
1224
+ const [searchParams] = useSearchParams()
1225
+ const requested = Number(searchParams.get("group") ?? "")
1226
+ const deepLinked = Number.isInteger(requested) && requested > 0 ? requested : null
1227
+ const [picked, setPicked] = useState<number | null>(null)
1228
+ /**
1229
+ * 「所属のみ」表示中に、この画面で外した人。
1230
+ *
1231
+ * ⚠️ 外した行をその場で消すと、下の行が **カーソルの下へせり上がる**。上から順に外していく
1232
+ * のは所属を整理する自然な手順であり、往復のたびに次の「外す」ボタンが今いる位置へ来る。
1233
+ * 確認も無いため、普通の速さで押している運用者が意図しない人を外す
1234
+ */
1235
+ const [justRemoved, setJustRemoved] = useState<ReadonlySet<number>>(new Set())
1236
+ /**
1237
+ * 深いリンクが指す先が変わったら、その指定に従う。
1238
+ *
1239
+ * ⚠️ 初期値としてだけ読むと、同じ面に居るまま `?group=` が変わっても (グループ面の各カードから
1240
+ * 続けて飛んだ場合) 選択が動かず、開いている面と URL が食い違う
1241
+ */
1242
+ const lastDeepLink = useRef<number | null>(deepLinked)
1243
+ if (lastDeepLink.current !== deepLinked) {
1244
+ lastDeepLink.current = deepLinked
1245
+ if (deepLinked !== null) {
1246
+ setPicked(deepLinked)
1247
+ // 別のグループの「外したばかり」を持ち越さない (押しボタン経由と同じ扱い)
1248
+ setJustRemoved(new Set())
1249
+ }
1250
+ }
1251
+
1252
+ const [memberQuery, setMemberQuery] = useState("")
1253
+ const [onlyMembers, setOnlyMembers] = useState(false)
681
1254
 
682
1255
  // 再検証で対象が消えることがある。消えた ID を握り続けると空の面になる
683
- const currentGroup = data.groups.find((g) => g.id === selectedGroupId) ?? data.groups[0]
684
- const currentMemberUserIds = new Set(data.groupMembers.filter((m) => m.groupId === selectedGroupId).map((m) => m.userId))
1256
+ const requestedGroupId = picked ?? deepLinked ?? data.groups[0]?.id ?? null
1257
+ const currentGroup = data.groups.find((g) => g.id === requestedGroupId) ?? data.groups[0]
1258
+ /**
1259
+ * 頼んだ相手と、実際に出ている相手が違うときの、頼んだ側の ID。
1260
+ *
1261
+ * ⚠️ 判定は「**頼んだ相手が出ているか**」で行う。「深いリンクで来て、まだ一度も選んでいない」
1262
+ * ことを条件にすると、一度でも一覧から選んだ後は永久に黙る — そして代替が出る場面の大半は
1263
+ * そちらである。**別の運用者が今開いているグループを消した** ときがそれで、画面の見出しだけが
1264
+ * 入れ替わり、続けて押した「除外」は選んだ覚えのないグループへ書かれる。書込先を画面に
1265
+ * 合わせてある (下の `handleToggleMember`) ことが、この沈黙を余計に危うくする
1266
+ */
1267
+ const missedTarget = requestedGroupId !== null && currentGroup !== undefined && currentGroup.id !== requestedGroupId ? requestedGroupId : null
1268
+ const currentMemberUserIds = new Set(data.groupMembers.filter((m) => m.groupId === currentGroup?.id).map((m) => m.userId))
1269
+ // 数百人の一覧を目で追わせない。名前と Email の両方を対象にする
1270
+ const needle = memberQuery.trim().toLowerCase()
1271
+ const visibleUsers = data.users.filter((u) => {
1272
+ // この画面で外した人は残す (行が消えると、下の行がカーソルの下へせり上がる)
1273
+ if (onlyMembers && !currentMemberUserIds.has(u.id) && !justRemoved.has(u.id)) return false
1274
+ if (needle === "") return true
1275
+ return u.displayName.toLowerCase().includes(needle) || u.email.toLowerCase().includes(needle)
1276
+ })
685
1277
 
686
1278
  const handleToggleMember = (userId: number, isMember: boolean) => {
1279
+ // ⚠️ `aria-disabled` は焦点を残すために `disabled` の代わりに使っている。押せてしまう
1280
+ // ぶん、実際に断るのはここである
1281
+ if (busy) return
1282
+ // ⚠️ 書き込む先は **画面に出ているグループ** でなければならない。選択 ID をそのまま送ると、
1283
+ // 再検証で対象が消えて代替が表示されている間に、見えていない別グループを書き換える
1284
+ if (!currentGroup) return
1285
+ const groupId = String(currentGroup.id)
687
1286
  if (isMember) {
688
- submitThen({ intent: "removeGroupMember", groupId: String(selectedGroupId), userId: String(userId) })
1287
+ setJustRemoved((current) => new Set(current).add(userId))
1288
+ submitThen({ intent: "removeGroupMember", groupId, userId: String(userId) })
689
1289
  } else {
690
- submitThen({ intent: "addGroupMember", groupId: String(selectedGroupId), userId: String(userId) })
1290
+ setJustRemoved((current) => {
1291
+ if (!current.has(userId)) return current
1292
+ const next = new Set(current)
1293
+ next.delete(userId)
1294
+ return next
1295
+ })
1296
+ submitThen({ intent: "addGroupMember", groupId, userId: String(userId) })
691
1297
  }
692
1298
  }
693
1299
 
@@ -695,16 +1301,25 @@ export function GroupMembersView() {
695
1301
  <section data-testid="group-members-view-section" style={{ display: "grid", gridTemplateColumns: "240px 1fr", gap: "1.2rem" }}>
696
1302
  <h2 style={{ ...heading, gridColumn: "1 / -1" }}>{labels.groupMembersView.title}</h2>
697
1303
  <ActionError result={actionResult} />
1304
+ {missedTarget !== null && (
1305
+ <p data-testid="member-deep-link-missed" role="note" style={{ gridColumn: "1 / -1", margin: 0, padding: "0.5rem 0.7rem", borderRadius: "6px", backgroundColor: "#fffbeb", border: "1px solid #fde68a", color: "#92400e", fontSize: "0.8rem" }}>
1306
+ {fill(labels.groupMembersView.deepLinkMissed, { id: missedTarget, group: currentGroup?.name ?? "" })}
1307
+ </p>
1308
+ )}
698
1309
  <div style={{ backgroundColor: "#ffffff", padding: "1rem", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
699
- <h4 style={{ margin: "0 0 0.8rem 0", fontSize: "0.9rem", color: "#475569" }}>{labels.groupMembersView.selectGroup}</h4>
1310
+ <h3 style={{ margin: "0 0 0.8rem 0", fontSize: "0.9rem", color: "#475569" }}>{labels.groupMembersView.selectGroup}</h3>
700
1311
  <div style={{ display: "flex", flexDirection: "column", gap: "0.4rem" }}>
701
1312
  {data.groups.map((g) => {
702
- const isSelected = g.id === selectedGroupId
1313
+ const isSelected = g.id === currentGroup?.id
703
1314
  return (
704
1315
  <button
705
1316
  type="button"
706
1317
  key={g.id}
707
- onClick={() => setSelectedGroupId(g.id)}
1318
+ onClick={() => {
1319
+ setPicked(g.id)
1320
+ // 別のグループの「外したばかり」を持ち越さない
1321
+ setJustRemoved(new Set())
1322
+ }}
708
1323
  data-testid={`select-group-btn-${g.id}`}
709
1324
  style={{
710
1325
  textAlign: "left",
@@ -743,8 +1358,44 @@ export function GroupMembersView() {
743
1358
  </div>
744
1359
  ) : null}
745
1360
  </div>
1361
+ <div style={{ display: "flex", gap: "0.75rem", alignItems: "center", flexWrap: "wrap", marginBottom: "0.8rem" }}>
1362
+ <input
1363
+ type="search"
1364
+ aria-label={labels.groupMembersView.searchLabel}
1365
+ placeholder={labels.groupMembersView.searchPlaceholder}
1366
+ value={memberQuery}
1367
+ onChange={(e) => setMemberQuery(e.target.value)}
1368
+ data-testid="member-search-input"
1369
+ style={{ flex: 1, minWidth: "220px", padding: "6px 12px", borderRadius: "6px", border: "1px solid #cbd5e1", fontSize: "0.85rem" }}
1370
+ />
1371
+ <label style={{ display: "inline-flex", alignItems: "center", gap: "0.35rem", fontSize: "0.8rem", color: "#475569" }}>
1372
+ <input
1373
+ type="checkbox"
1374
+ data-testid="member-only-toggle"
1375
+ checked={onlyMembers}
1376
+ onChange={(e) => {
1377
+ setOnlyMembers(e.target.checked)
1378
+ // 絞り込みを掛け直したら、その時点の所属で数え直す。
1379
+ // 残すと「外したはずの人が所属のみ表示に居る」状態が続く
1380
+ setJustRemoved(new Set())
1381
+ }}
1382
+ />
1383
+ {fill(labels.groupMembersView.onlyMembers, { count: currentMemberUserIds.size })}
1384
+ </label>
1385
+ </div>
1386
+ {/* 絞り込みの結果は視覚的にしか変わらない。読み上げへ届けるには live region が要る */}
1387
+ <p data-testid="member-summary" role="status" aria-live="polite" style={{ margin: "0 0 0.6rem 0", fontSize: "0.78rem", color: "#475569" }}>
1388
+ {fill(labels.groupMembersView.summary, { shown: visibleUsers.length, total: data.users.length, members: currentMemberUserIds.size })}
1389
+ </p>
1390
+ {visibleUsers.length === 0 && (
1391
+ <p data-testid="member-empty-filtered" style={{ margin: "0 0 0.6rem 0", fontSize: "0.85rem", color: "#475569" }}>
1392
+ {/* 効いている絞り込みを名指しする。打っていない検索のせいにしない。
1393
+ そもそも利用者が 1 人も居ない配備を「検索で 0 件」と言わない */}
1394
+ {data.users.length === 0 ? labels.groupMembersView.noUsers : needle === "" && onlyMembers ? labels.groupMembersView.emptyNoMembers : labels.groupMembersView.emptyFiltered}
1395
+ </p>
1396
+ )}
746
1397
  <div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
747
- {data.users.map((u) => {
1398
+ {visibleUsers.map((u) => {
748
1399
  const isMember = currentMemberUserIds.has(u.id)
749
1400
  return (
750
1401
  <div
@@ -773,8 +1424,7 @@ export function GroupMembersView() {
773
1424
  <button
774
1425
  type="button"
775
1426
  onClick={() => handleToggleMember(u.id, isMember)}
776
- disabled={busy}
777
- aria-busy={busy}
1427
+ {...rowActionProps(busy)}
778
1428
  aria-label={fill(isMember ? labels.groupMembersView.actionRemoveAria : labels.groupMembersView.actionAddAria, { user: u.displayName })}
779
1429
  data-testid={`toggle-member-btn-${u.id}`}
780
1430
  style={{
@@ -796,7 +1446,10 @@ export function GroupMembersView() {
796
1446
  </div>
797
1447
  </>
798
1448
  ) : (
799
- <div style={{ color: "#475569", textAlign: "center", padding: "2rem" }}>{labels.groupMembersView.selectPrompt}</div>
1449
+ <div data-testid="member-no-groups" style={{ color: "#475569", textAlign: "center", padding: "2rem" }}>
1450
+ {/* 左の一覧が空なら「選べ」は従えない指示である。何が無いのかを言う */}
1451
+ {data.groups.length === 0 ? labels.groupMembersView.noGroups : labels.groupMembersView.selectPrompt}
1452
+ </div>
800
1453
  )}
801
1454
  </div>
802
1455
  </section>
@@ -906,11 +1559,21 @@ export function AllowlistView() {
906
1559
  <table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.85rem", textAlign: "left" }}>
907
1560
  <thead>
908
1561
  <tr style={{ backgroundColor: "#f8fafc", borderBottom: "1px solid #e2e8f0", color: "#475569" }}>
909
- <th style={{ padding: "10px 14px" }}>{labels.common.id}</th>
910
- <th style={{ padding: "10px 14px" }}>{labels.allowlistView.target}</th>
911
- <th style={{ padding: "10px 14px" }}>{labels.allowlistView.type}</th>
912
- <th style={{ padding: "10px 14px" }}>{labels.allowlistView.description}</th>
913
- <th style={{ padding: "10px 14px", textAlign: "right" }}>{labels.common.actions}</th>
1562
+ <th scope="col" style={{ padding: "10px 14px" }}>
1563
+ {labels.common.id}
1564
+ </th>
1565
+ <th scope="col" style={{ padding: "10px 14px" }}>
1566
+ {labels.allowlistView.target}
1567
+ </th>
1568
+ <th scope="col" style={{ padding: "10px 14px" }}>
1569
+ {labels.allowlistView.type}
1570
+ </th>
1571
+ <th scope="col" style={{ padding: "10px 14px" }}>
1572
+ {labels.allowlistView.description}
1573
+ </th>
1574
+ <th scope="col" style={{ padding: "10px 14px", textAlign: "right" }}>
1575
+ {labels.common.actions}
1576
+ </th>
914
1577
  </tr>
915
1578
  </thead>
916
1579
  <tbody>
@@ -953,7 +1616,7 @@ export function AllowlistView() {
953
1616
  type="button"
954
1617
  onClick={() => {
955
1618
  // これでしかサインインできない人はログインできなくなる。取り消せない
956
- if (!window.confirm(labels.allowlistView.confirmDelete)) return
1619
+ if (!window.confirm(fill(labels.allowlistView.confirmDelete, { target: item.type === "group" ? item.groupName : item.pattern }))) return
957
1620
  if (item.type === "group") handleRemoveGroupEntry(item.id)
958
1621
  else handleRemoveEntry(item.id)
959
1622
  }}
@@ -1000,6 +1663,10 @@ export function DirectoryView() {
1000
1663
  const linkable = data.groups.filter((g) => !g.externalLink)
1001
1664
  const [groupId, setGroupId] = useState("")
1002
1665
  const [externalKey, setExternalKey] = useState("")
1666
+ // ⚠️ 選んだ候補の **安定 ID** を別に持つ。アドレスだけで引き当てると、同じアドレスの行が
1667
+ // 2 つある配備 (別名行、改名の途中) で、選んだのとは違う行が控えに出る。手入力に切り替えた
1668
+ // ときは空に戻す — 手入力は識別子を証明できないので、サーバー側もアドレスで解決する
1669
+ const [pickedExternalId, setPickedExternalId] = useState("")
1003
1670
  const [membershipMode, setMembershipMode] = useState<AuthDirectoryMembershipMode>("direct")
1004
1671
 
1005
1672
  const linked = data.groups.filter((g) => g.externalLink)
@@ -1007,19 +1674,54 @@ export function DirectoryView() {
1007
1674
  const allowlistEntryOf = (id: number) => data.allowlist.find((e) => e.type === "group" && e.groupId === id)
1008
1675
  const busy = fetcher.state !== "idle"
1009
1676
  // 「要求しました」を出す対象。押しても何も変わらないと、何度も押される
1010
- const [queuedGroupId, setQueuedGroupId] = useState<number | null>(null)
1677
+ const [queued, setQueued] = useState<{ groupId: number; syncedAtAtPress: string | null } | null>(null)
1678
+ /**
1679
+ * 直前の「今すぐ同期」が積まれなかったことの通知。
1680
+ *
1681
+ * ⚠️ 積まれなかったことを成功と同じ見た目にしてはならない。同じ範囲の要求が既に待っている
1682
+ * とき積み増さないのは正しいが、黙って成功に見せると、押しても何も起きない画面になる —
1683
+ * しかもその待っている行が誰にも拾われなければ、以後の押下すべてが同じように消える
1684
+ */
1685
+ const syncRequestNotice = typeof actionResult === "object" && actionResult !== null && (actionResult as { syncRequest?: unknown }).syncRequest === "already-pending" ? L.syncAlreadyPending : null
1686
+ /**
1687
+ * 「要求しました」を出し続けてよいグループ。
1688
+ *
1689
+ * ⚠️ **押した時刻より後に同期が成功していたら、もう出さない。** 出し続けると、運用者は
1690
+ * 新しく押した要求と古い表示を見分けられず、同期が終わったのかどうかも判らない。
1691
+ * 同期の完了は `syncedAt` が進んだことで判る — それが唯一の観測点である
1692
+ */
1693
+ const queuedGroupId = (() => {
1694
+ if (queued === null) return null
1695
+ const target = data.groups.find((g) => g.id === queued.groupId)
1696
+ const syncedAt = target?.externalLink?.syncedAt ?? null
1697
+ // ⚠️ 端末の時計を判定に使わない。押した時刻 (ブラウザ) と同期時刻 (サーバー) を比べると、
1698
+ // 進んだ時計では控えが永久に消えず、遅れた時計では押した直後に消える。**押した瞬間に
1699
+ // 見えていた同期時刻から変わったか** だけを見る — どちらもサーバーが作った値である
1700
+ if (syncedAt !== queued.syncedAtAtPress) return null
1701
+ return queued.groupId
1702
+ })()
1011
1703
  // 供給元が判らないまま欄を出すと、送信は必ずサーバー側の内部文言で失敗する
1012
1704
  const canLink = data.directoryStatus.isEnabled && data.directoryStatus.provider !== ""
1705
+ const catalogOptions = useMemo(() => upstreamOptions(data.catalog, upstreamIndex(linked), labels), [data.catalog, linked, labels])
1706
+ // ⚠️ 選べる候補が 1 件も無いなら、選択欄ではなく手入力へ倒す。行数で判定すると、全件が
1707
+ // 連携済みの配備で「すべて選べない選択欄」だけが出て、手入力欄まで消える — 連携だけが
1708
+ // 目的のこの面で、打つ手が 1 つも無くなる
1709
+ const selectableCatalogCount = useMemo(() => catalogOptions.filter((o) => o.disabled !== true).length, [catalogOptions])
1013
1710
 
1014
1711
  /** 連携を作る。上流キーは運用者が知っている値 (グループアドレス) で受け取る。 */
1015
1712
  const handleLink = (e: React.FormEvent) => {
1016
1713
  e.preventDefault()
1017
1714
  if (!(groupId && externalKey)) return
1715
+ // 連携すると名前・説明・メンバー・削除のすべてが上流の管理下へ移る。作成側と同じく、
1716
+ // 戻せない操作であることを先に伝える
1717
+ const target = linkable.find((g) => String(g.id) === groupId)
1718
+ if (!window.confirm(fill(L.confirmLink, { group: target?.name ?? groupId, upstream: externalKey }))) return
1018
1719
  // 供給元は配備の設定から来る。ここに特定の値を書くと、別の上流を使う配備で作った
1019
1720
  // リンクが同期側で恒久的に拒否される
1020
1721
  const sentKey = externalKey
1021
- submitThen({ intent: "linkDirectoryGroup", groupId, provider: data.directoryStatus.provider, externalKey: sentKey, membershipMode }, () => {
1722
+ submitThen({ intent: "linkDirectoryGroup", groupId, provider: data.directoryStatus.provider, externalKey: sentKey, externalId: pickedExternalId, membershipMode }, () => {
1022
1723
  setExternalKey((current) => (current === sentKey ? "" : current))
1724
+ setPickedExternalId((current) => (externalKey === sentKey ? "" : current))
1023
1725
  // 連携済みになった行は候補から消える。選択を残すと、表示は空欄なのに前の
1024
1726
  // グループへ送信される
1025
1727
  setGroupId("")
@@ -1043,6 +1745,18 @@ export function DirectoryView() {
1043
1745
  </p>
1044
1746
  )}
1045
1747
 
1748
+ {/* ⚠️ ライブリージョンは **常に置いておき、中身だけ入れ替える**。内容と一緒に挿入した
1749
+ `role="status"` は読み上げられない — 押しても何も起きなかったことを伝える唯一の
1750
+ 手段が、その伝えたい場面でだけ無言になる */}
1751
+ <p
1752
+ data-testid="directory-sync-already-pending"
1753
+ role="status"
1754
+ aria-live="polite"
1755
+ hidden={syncRequestNotice === null}
1756
+ style={syncRequestNotice === null ? undefined : { margin: 0, padding: "0.5rem 0.7rem", borderRadius: "6px", backgroundColor: "#fffbeb", border: "1px solid #fde68a", color: "#92400e", fontSize: "0.8rem" }}>
1757
+ {syncRequestNotice}
1758
+ </p>
1759
+
1046
1760
  {canLink && linkable.length > 0 && (
1047
1761
  <form onSubmit={handleLink} data-testid="directory-link-form" style={{ display: "flex", gap: "0.75rem", flexWrap: "wrap", alignItems: "flex-end", backgroundColor: "#ffffff", padding: "1rem", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
1048
1762
  <div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
@@ -1058,20 +1772,83 @@ export function DirectoryView() {
1058
1772
  ))}
1059
1773
  </select>
1060
1774
  </div>
1061
- <div style={{ display: "flex", flexDirection: "column", gap: "0.25rem", flex: 1, minWidth: "220px" }}>
1062
- <label htmlFor="directory-external-key-input" style={fieldLabel}>
1063
- {L.externalKey}
1064
- </label>
1065
- <input
1066
- id="directory-external-key-input"
1067
- type="email"
1068
- required
1069
- data-testid="directory-external-key"
1070
- placeholder={L.externalKeyPlaceholder}
1071
- value={externalKey}
1072
- onChange={(e) => setExternalKey(e.target.value)}
1073
- style={{ padding: "0.5rem", borderRadius: "6px", border: "1px solid #cbd5e1" }}
1074
- />
1775
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.25rem", flex: 1, minWidth: "260px" }}>
1776
+ {/* 候補一覧が引けているなら選ばせる。手入力は綴り違いを「後で赤いバナー」に変える */}
1777
+ {/* ⚠️ 「読めたが 1 件も無い」で選択欄へ倒すと、選べない選択欄と押せない
1778
+ ボタンという終端になる。しかも手入力欄まで消えるので、連携だけが目的の
1779
+ この面で打つ手が無くなる。作成対話と同じ条件で扱う */}
1780
+ {data.catalog.kind === "ready" && data.catalog.groups.length > 0 ? (
1781
+ <>
1782
+ <div id="directory-external-key-label" style={fieldLabel}>
1783
+ {L.externalKey}
1784
+ </div>
1785
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: 子の暗黙送信を止めるためだけの捕捉であり、対話要素ではない */}
1786
+ <div
1787
+ data-testid="directory-external-key-picker"
1788
+ onKeyDown={(event) => {
1789
+ // 絞り込みを打った直後の Enter が、前回選んだ上流のまま連携を作ってしまう
1790
+ // ⚠️ 変換中の Enter には触れない。日本語 IME の確定は
1791
+ // `keydown{key:"Enter", keyCode:229, isComposing:true}` として届き、
1792
+ // ここで既定動作を止めると変換の確定そのものを潰し得る。
1793
+ // 変換中はそもそも暗黙の送信は起きないので、止める意味も無い
1794
+ if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return
1795
+ if (event.key === "Enter") event.preventDefault()
1796
+ }}>
1797
+ <SelectBox
1798
+ options={catalogOptions}
1799
+ // 選んだ後も **名前とアドレス** を見せる。生のアドレスだけに戻すと、
1800
+ // 名前で選んだ運用者に機械的なキーを突き返すことになる
1801
+ value={pickedExternalId === "" ? null : (catalogOptions.find((o) => o.value === pickedExternalId) ?? null)}
1802
+ onChange={(picked: Option | Option[] | null) => {
1803
+ if (picked === null || Array.isArray(picked)) {
1804
+ setPickedExternalId("")
1805
+ setExternalKey("")
1806
+ return
1807
+ }
1808
+ setPickedExternalId(String(picked.value))
1809
+ setExternalKey((picked as UpstreamOption).entry.externalKey)
1810
+ }}
1811
+ placeholder={L.pickUpstreamPlaceholder}
1812
+ ariaLabelledBy="directory-external-key-label"
1813
+ renderOption={renderUpstreamOption}
1814
+ texts={pickerTexts(labels)}
1815
+ clearable
1816
+ />
1817
+ </div>
1818
+ <CatalogFreshness observedAt={data.catalog.observedAt} isStale={data.catalog.isStale} labels={labels} />
1819
+ </>
1820
+ ) : (
1821
+ <>
1822
+ <label htmlFor="directory-external-key-input" style={fieldLabel}>
1823
+ {L.externalKey}
1824
+ </label>
1825
+ <input
1826
+ id="directory-external-key-input"
1827
+ type="email"
1828
+ required
1829
+ data-testid="directory-external-key"
1830
+ placeholder={L.externalKeyPlaceholder}
1831
+ value={externalKey}
1832
+ onChange={(e) => {
1833
+ // 手入力は識別子を証明できない。控えの安定 ID は捨て、
1834
+ // サーバー側にもアドレスで解決させる
1835
+ setPickedExternalId("")
1836
+ setExternalKey(e.target.value)
1837
+ }}
1838
+ style={{ padding: "0.5rem", borderRadius: "6px", border: "1px solid #cbd5e1" }}
1839
+ />
1840
+ {data.catalog.kind === "unavailable" && (
1841
+ <span data-testid="directory-catalog-unavailable" style={{ fontSize: "0.72rem", color: "#92400e" }}>
1842
+ {fill(labels.groupsView.catalogUnavailable, { reason: labels.groupsView.catalogFailures[data.catalog.reason] })}
1843
+ </span>
1844
+ )}
1845
+ {data.catalog.kind === "ready" && (
1846
+ <span data-testid="directory-catalog-empty" style={{ fontSize: "0.72rem", color: "#475569" }}>
1847
+ {fill(labels.groupsView.catalogEmpty, { at: formatDateTime(data.catalog.observedAt, labels) })}
1848
+ </span>
1849
+ )}
1850
+ </>
1851
+ )}
1075
1852
  </div>
1076
1853
  <div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
1077
1854
  <label htmlFor="directory-membership-mode-input" style={fieldLabel}>
@@ -1106,12 +1883,24 @@ export function DirectoryView() {
1106
1883
  <table data-testid="directory-table" style={{ width: "100%", borderCollapse: "collapse", backgroundColor: "#ffffff", borderRadius: "8px", overflow: "hidden", border: "1px solid #e2e8f0" }}>
1107
1884
  <thead>
1108
1885
  <tr style={{ backgroundColor: "#f8fafc", textAlign: "left" }}>
1109
- <th style={cell}>{labels.groupsView.name}</th>
1110
- <th style={cell}>{L.externalKey}</th>
1111
- <th style={cell}>{L.membershipMode}</th>
1112
- <th style={cell}>{L.lastSynced}</th>
1113
- <th style={cell}>{labels.common.status}</th>
1114
- <th style={cell}>{labels.common.actions}</th>
1886
+ <th scope="col" style={cell}>
1887
+ {labels.groupsView.name}
1888
+ </th>
1889
+ <th scope="col" style={cell}>
1890
+ {L.externalKey}
1891
+ </th>
1892
+ <th scope="col" style={cell}>
1893
+ {L.membershipMode}
1894
+ </th>
1895
+ <th scope="col" style={cell}>
1896
+ {L.lastSynced}
1897
+ </th>
1898
+ <th scope="col" style={cell}>
1899
+ {labels.common.status}
1900
+ </th>
1901
+ <th scope="col" style={cell}>
1902
+ {labels.common.actions}
1903
+ </th>
1115
1904
  </tr>
1116
1905
  </thead>
1117
1906
  <tbody>
@@ -1154,21 +1943,27 @@ export function DirectoryView() {
1154
1943
  <button
1155
1944
  type="button"
1156
1945
  data-testid={`directory-sync-now-${g.id}`}
1157
- disabled={busy}
1158
- aria-busy={busy}
1946
+ {...rowActionProps(busy)}
1159
1947
  aria-label={fill(queuedGroupId === g.id ? L.syncQueuedAria : L.syncNowAria, { group: g.name })}
1160
- onClick={() => submitThen({ intent: "requestDirectorySync", groupId: String(g.id) }, () => setQueuedGroupId(g.id))}
1948
+ onClick={() =>
1949
+ submitThen({ intent: "requestDirectorySync", groupId: String(g.id) }, (result) => {
1950
+ // ⚠️ 積まれたときだけ控えを出す。既に待っている要求があって積み増さなかった場合も
1951
+ // 受理として返るため、区別せずに控えを出すと、押した本人には「要求しました」と
1952
+ // 見えるのに、その下の説明文は「何も積まれていません」と言う画面になる
1953
+ if ((result as { syncRequest?: unknown } | undefined)?.syncRequest !== "queued") return
1954
+ setQueued({ groupId: g.id, syncedAtAtPress: g.externalLink?.syncedAt ?? null })
1955
+ })
1956
+ }
1161
1957
  style={smallButton}>
1162
1958
  {queuedGroupId === g.id ? L.syncQueued : L.syncNow}
1163
1959
  </button>
1164
1960
  <button
1165
1961
  type="button"
1166
1962
  data-testid={`directory-toggle-sync-${g.id}`}
1167
- disabled={busy}
1168
- aria-busy={busy}
1963
+ {...rowActionProps(busy)}
1169
1964
  aria-label={fill(link.isSyncEnabled ? L.pauseAria : L.resumeAria, { group: g.name })}
1170
1965
  onClick={() => {
1171
- setQueuedGroupId(null)
1966
+ setQueued(null)
1172
1967
  submitThen({ intent: "setDirectorySyncEnabled", groupId: String(g.id), isSyncEnabled: String(!link.isSyncEnabled) })
1173
1968
  }}
1174
1969
  style={smallButton}>
@@ -1177,14 +1972,13 @@ export function DirectoryView() {
1177
1972
  <button
1178
1973
  type="button"
1179
1974
  data-testid={`directory-allow-${g.id}`}
1180
- disabled={busy}
1181
- aria-busy={busy}
1975
+ {...rowActionProps(busy)}
1182
1976
  aria-label={fill(allowed ? L.disallowGroupAria : L.allowGroupAria, { group: g.name })}
1183
1977
  onClick={() => {
1184
1978
  const entry = allowlistEntryOf(g.id)
1185
1979
  if (allowed && entry) {
1186
1980
  // 取り消しはサインインを止める。追加は元に戻すだけなので確認しない
1187
- if (!window.confirm(L.confirmDisallow)) return
1981
+ if (!window.confirm(fill(L.confirmDisallow, { group: g.name }))) return
1188
1982
  submitThen({ intent: "removeAllowlistGroup", entryId: String(entry.id) })
1189
1983
  } else {
1190
1984
  submitThen({ intent: "addAllowlistGroup", groupId: String(g.id) })
@@ -1196,12 +1990,11 @@ export function DirectoryView() {
1196
1990
  <button
1197
1991
  type="button"
1198
1992
  data-testid={`directory-unlink-${g.id}`}
1199
- disabled={busy}
1200
- aria-busy={busy}
1993
+ {...rowActionProps(busy)}
1201
1994
  aria-label={fill(L.unlinkAria, { group: g.name })}
1202
1995
  onClick={() => {
1203
1996
  // 台帳とグループ単位の許可が失われる。取り消せないので必ず確認する
1204
- if (!window.confirm(L.confirmUnlink)) return
1997
+ if (!window.confirm(fill(L.confirmUnlink, { group: g.name, upstream: g.externalLink?.externalKey ?? "" }))) return
1205
1998
  submitThen({ intent: "unlinkDirectoryGroup", groupId: String(g.id) })
1206
1999
  }}
1207
2000
  style={{ ...smallButton, color: "#b91c1c", borderColor: "#fecaca" }}>
@@ -1221,11 +2014,37 @@ export function DirectoryView() {
1221
2014
 
1222
2015
  /** フォーム項目の見出し。プレースホルダは入力すると消えるため名前にならない。 */
1223
2016
  const fieldLabel: React.CSSProperties = { fontSize: "0.78rem", color: "#475569", fontWeight: 600 }
2017
+
2018
+ /**
2019
+ * 目には出さないが、読み上げと焦点には残す指定。
2020
+ *
2021
+ * `display: none` や `visibility: hidden` はアクセシビリティツリーからも消えるため使えない。
2022
+ * ここで隠すのはラジオ入力そのもので、隠しても **焦点は当たり続けなければならない** (矢印キーで
2023
+ * 移動できるのはブラウザが焦点を持てるからである)。
2024
+ */
2025
+ const visuallyHidden: React.CSSProperties = { position: "absolute", width: "1px", height: "1px", padding: 0, margin: "-1px", overflow: "hidden", clipPath: "inset(50%)", whiteSpace: "nowrap", border: 0 }
1224
2026
  /** 各ビューの見出し。段階を飛ばさないよう h2 に固定する。 */
1225
2027
  const heading: React.CSSProperties = { margin: 0, fontSize: "1.05rem", fontWeight: 700, color: "#0f172a" }
1226
2028
  /** 表セルの共通スタイル。 */
1227
2029
  const cell: React.CSSProperties = { padding: "0.6rem 0.75rem", fontSize: "0.85rem", verticalAlign: "top" }
1228
2030
  /** 行内の小さな操作ボタン。 */
2031
+ /**
2032
+ * Marks a row action as unavailable without taking it out of the tab order.
2033
+ * 行の操作を「今は使えない」と示しつつ、タブ順からは外さない処理。
2034
+ *
2035
+ * ⚠️ `disabled` を使わない。HTML 上の無効化は **焦点を持てない要素** にするため、キーボードで
2036
+ * 押した瞬間にその要素が無効になり、ブラウザは焦点を `<body>` へ落とす。しかも busy は表全体で
2037
+ * 共有しているので、1 行を押しただけで表中の全ボタンが焦点を失う — 応答が返ったあと、操作者は
2038
+ * 文書の先頭から Tab で戻ることになる。`aria-disabled` は焦点を残したまま「使えない」を伝える。
2039
+ * 実際に押させないのは呼び出し側の早期 return である。
2040
+ *
2041
+ * @param busy - Whether a submission is in flight. 送信中かどうか。
2042
+ * @returns Props to spread onto the button. ボタンへ展開する属性。
2043
+ */
2044
+ function rowActionProps(busy: boolean): { "aria-disabled": boolean; "aria-busy": boolean; "data-busy": string | undefined } {
2045
+ return { "aria-disabled": busy, "aria-busy": busy, "data-busy": busy ? "true" : undefined }
2046
+ }
2047
+
1229
2048
  const smallButton: React.CSSProperties = { padding: "0.25rem 0.6rem", fontSize: "0.75rem", borderRadius: "6px", border: "1px solid #cbd5e1", backgroundColor: "#fff", cursor: "pointer" }
1230
2049
 
1231
2050
  const SEGMENT_VIEWS: Record<string, () => React.ReactElement> = {