@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.
- package/README.md +73 -0
- package/dist/admin.d.mts +309 -11
- package/dist/admin.d.ts +309 -11
- package/dist/admin.js +1 -1
- package/dist/admin.js.map +1 -1
- package/dist/admin.mjs +1 -1
- package/dist/admin.mjs.map +1 -1
- package/dist/styles/auth-react-router.standalone.css +1 -1
- package/package.json +12 -3
- package/src/admin/app.ts +5 -0
- package/src/admin/format.ts +10 -1
- package/src/admin/labels.ts +289 -15
- package/src/admin/server.ts +549 -37
- package/src/admin/ui.tsx +17 -4
- package/src/admin/views.tsx +913 -94
package/src/admin/views.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 = (
|
|
442
|
-
|
|
443
|
-
|
|
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 =
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
|
|
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
|
|
522
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
684
|
-
const
|
|
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
|
-
|
|
1287
|
+
setJustRemoved((current) => new Set(current).add(userId))
|
|
1288
|
+
submitThen({ intent: "removeGroupMember", groupId, userId: String(userId) })
|
|
689
1289
|
} else {
|
|
690
|
-
|
|
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
|
-
<
|
|
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 ===
|
|
1313
|
+
const isSelected = g.id === currentGroup?.id
|
|
703
1314
|
return (
|
|
704
1315
|
<button
|
|
705
1316
|
type="button"
|
|
706
1317
|
key={g.id}
|
|
707
|
-
onClick={() =>
|
|
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
|
-
{
|
|
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
|
-
|
|
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" }}>
|
|
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" }}>
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
<th style={{ padding: "10px 14px" }}>
|
|
913
|
-
|
|
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 [
|
|
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: "
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
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}>
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
<th style={cell}>
|
|
1113
|
-
|
|
1114
|
-
|
|
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
|
-
|
|
1158
|
-
aria-busy={busy}
|
|
1946
|
+
{...rowActionProps(busy)}
|
|
1159
1947
|
aria-label={fill(queuedGroupId === g.id ? L.syncQueuedAria : L.syncNowAria, { group: g.name })}
|
|
1160
|
-
onClick={() =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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> = {
|