@aiquants/auth-react-router 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -7
- package/dist/admin.d.mts +13 -436
- package/dist/admin.d.ts +13 -436
- package/dist/admin.js +1 -2
- package/dist/admin.mjs +1 -2
- package/dist/client.d.mts +1 -10
- package/dist/client.d.ts +1 -10
- package/dist/client.js +0 -1
- package/dist/client.mjs +0 -1
- package/dist/server.d.mts +6 -146
- package/dist/server.d.ts +6 -146
- package/dist/server.js +0 -1
- package/dist/server.mjs +0 -1
- package/package.json +12 -16
- package/dist/admin.js.map +0 -1
- package/dist/admin.mjs.map +0 -1
- package/dist/client.js.map +0 -1
- package/dist/client.mjs.map +0 -1
- package/dist/server.js.map +0 -1
- package/dist/server.mjs.map +0 -1
- package/src/admin/app.ts +0 -9
- package/src/admin/format.ts +0 -58
- package/src/admin/labels.ts +0 -838
- package/src/admin/server.ts +0 -1143
- package/src/admin/ui.tsx +0 -276
- package/src/admin/views.tsx +0 -2120
- package/src/admin.ts +0 -5
- package/src/client/GoogleForm.tsx +0 -47
- package/src/client.ts +0 -6
- package/src/server/auth-server.ts +0 -69
- package/src/server/authenticate-in-loader.ts +0 -243
- package/src/server/authenticate.ts +0 -224
- package/src/server/google-strategy-impl.ts +0 -154
- package/src/server/google-strategy.ts +0 -183
- package/src/server/handlers.ts +0 -139
- package/src/server/logger.ts +0 -14
- package/src/server/ports.ts +0 -113
- package/src/server/session.ts +0 -132
- package/src/server.ts +0 -14
- package/src/styles/standalone.entry.css +0 -6
package/src/admin/views.tsx
DELETED
|
@@ -1,2120 +0,0 @@
|
|
|
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
|
-
*/
|
|
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"
|
|
11
|
-
import type React from "react"
|
|
12
|
-
import { useEffect, useMemo, useRef, useState } from "react"
|
|
13
|
-
import { Link, useFetcher, useLoaderData, useSearchParams } from "react-router"
|
|
14
|
-
import { fill, formatDateTime } from "./format"
|
|
15
|
-
import { defaultUserAdminLabels, type UserAdminLabels } from "./labels"
|
|
16
|
-
import { normalizeUpstreamKey, type UserAdminCatalog, type UserAdminCatalogGroup } from "./server"
|
|
17
|
-
import { UserAdminShell, type UserAdminShellProps } from "./ui"
|
|
18
|
-
|
|
19
|
-
export type UserAdminLoaderData = {
|
|
20
|
-
segment: string
|
|
21
|
-
users: AuthUser[]
|
|
22
|
-
groups: AuthGroup[]
|
|
23
|
-
groupMembers: AuthGroupMember[]
|
|
24
|
-
allowlist: AuthAllowlistEntry[]
|
|
25
|
-
directoryStatus: AuthDirectoryStatus
|
|
26
|
-
/** 上流の候補一覧。選択欄を出す面でのみ `ready` になる。 */
|
|
27
|
-
catalog: UserAdminCatalog
|
|
28
|
-
labels?: UserAdminLabels
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Renders the error returned by the last action submission, if any.
|
|
33
|
-
* 直近の action 送信が返したエラーを表示する部品。
|
|
34
|
-
*
|
|
35
|
-
* ロックアウト理由・重複パターン・入力不正などの文言は action が `{ok:false,error}` で返す。
|
|
36
|
-
* 表示しなければ操作が黙って失敗したように見えるため、各ビューの先頭に必ず置く。
|
|
37
|
-
*/
|
|
38
|
-
function ActionError({ result: raw, id }: { result: unknown; id?: string }) {
|
|
39
|
-
const result = raw as { ok?: boolean; error?: string } | undefined
|
|
40
|
-
if (result?.ok !== false || !result.error) return null
|
|
41
|
-
return (
|
|
42
|
-
// グリッド配下でも行を跨ぐ。空のときは描かないので、余分な行も生まれない
|
|
43
|
-
<div id={id} data-testid="user-admin-action-error" role="alert" style={{ gridColumn: "1 / -1", padding: "0.7rem 1rem", borderRadius: "6px", border: "1px solid #fecaca", backgroundColor: "#fef2f2", color: "#b91c1c", fontSize: "0.85rem" }}>
|
|
44
|
-
{result.error}
|
|
45
|
-
</div>
|
|
46
|
-
)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Submits through the view's single fetcher and defers "close and clear" until the server accepts.
|
|
51
|
-
* ビュー唯一の fetcher で送信し、**サーバーが受理してから** 「閉じて消す」を行うためのフック。
|
|
52
|
-
*
|
|
53
|
-
* 送信直後に閉じて入力を消すと、認可拒否や検証エラーで戻ったときに打ち込んだ内容が丸ごと
|
|
54
|
-
* 失われ、利用者は全部打ち直すことになる。成功応答 (`ok === true`) を待って初めて破棄する。
|
|
55
|
-
*
|
|
56
|
-
* ⚠️ **このビューの送信はすべてここを通すこと。** fetcher は 1 つを共有しており、後の送信は
|
|
57
|
-
* 前の送信を打ち切る。素の `fetcher.submit` を混ぜると、打ち切られた送信の後始末が **後の
|
|
58
|
-
* 送信の成功応答で** 実行される (行の操作を挟んだだけで、フォームの入力が消える)。ここを
|
|
59
|
-
* 通せば後の送信が `pending` を必ず上書きするため、後始末は常に最後の送信のものになる。
|
|
60
|
-
*/
|
|
61
|
-
function useSubmitThen(fetcher: ReturnType<typeof useFetcher>) {
|
|
62
|
-
const pending = useRef<((data: unknown) => void) | null>(null)
|
|
63
|
-
// 破棄した結果の世代。`fetcher.data` は宣言的に消せないため、こちらで無効化する
|
|
64
|
-
const [discarded, setDiscarded] = useState<unknown>(undefined)
|
|
65
|
-
const result = fetcher.data === discarded ? undefined : fetcher.data
|
|
66
|
-
|
|
67
|
-
useEffect(() => {
|
|
68
|
-
if (fetcher.state !== "idle" || fetcher.data === undefined) return
|
|
69
|
-
const onSuccess = pending.current
|
|
70
|
-
if (!onSuccess) return
|
|
71
|
-
// 同じ応答で 2 度実行しないよう、消費した時点で捨てる。ここは **冗長な防御** である —
|
|
72
|
-
// すべての送信がこのフックを通る限り、後の送信が必ず `pending` を上書きするため。
|
|
73
|
-
// 素の `fetcher.submit` が 1 つでも混ざった瞬間に効き始める
|
|
74
|
-
pending.current = null
|
|
75
|
-
// ⚠️ 後始末には応答そのものを渡す。`ok === true` は「受理した」であって「要求どおりに
|
|
76
|
-
// なった」ではない — 積まれなかった要求も受理として返る。区別できない形にすると、
|
|
77
|
-
// 押した控えだけが成功の見た目で残る
|
|
78
|
-
if ((fetcher.data as { ok?: boolean }).ok === true) onSuccess(fetcher.data)
|
|
79
|
-
}, [fetcher.state, fetcher.data])
|
|
80
|
-
|
|
81
|
-
const submit = (payload: Record<string, string>, onSuccess?: (data: unknown) => void) => {
|
|
82
|
-
pending.current = onSuccess ?? null
|
|
83
|
-
setDiscarded(undefined)
|
|
84
|
-
fetcher.submit(payload, { method: "post" })
|
|
85
|
-
}
|
|
86
|
-
/**
|
|
87
|
-
* Forgets the last result so an unrelated failure cannot describe a freshly opened dialog.
|
|
88
|
-
* 直前の結果を忘れる処理 (無関係な失敗が、開いたばかりのダイアログの説明文にならないように)。
|
|
89
|
-
*/
|
|
90
|
-
const resetResult = () => setDiscarded(fetcher.data)
|
|
91
|
-
|
|
92
|
-
return { submit, resetResult, result }
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Gives a dialog the behavior its ARIA attributes promise: focus, trap, Escape, restore.
|
|
97
|
-
* ダイアログに、ARIA 属性が約束している挙動 (初期焦点・封じ込め・Escape・復帰) を与えるフック。
|
|
98
|
-
*
|
|
99
|
-
* 役割属性だけを付けても、焦点は背後に残り、Tab は外へ出て行き、Escape は効かない。
|
|
100
|
-
* キーボード利用者は「閉じる」に到達できず、支援技術は背後を読み続ける。
|
|
101
|
-
*
|
|
102
|
-
* @param isOpen Whether the dialog is mounted and visible. ダイアログが開いているかどうか。
|
|
103
|
-
* @param onClose Invoked when Escape is pressed. Escape が押されたときに呼ばれる処理。
|
|
104
|
-
* @returns Ref to attach to the dialog panel. ダイアログ本体へ付ける ref。
|
|
105
|
-
*/
|
|
106
|
-
function useModalBehavior(isOpen: boolean, onClose: () => void, openerRef?: React.RefObject<HTMLElement | null>) {
|
|
107
|
-
const ref = useRef<HTMLFormElement>(null)
|
|
108
|
-
// 再描画のたびに実行されると入力中に焦点が先頭へ飛ぶため、依存は開閉だけにする
|
|
109
|
-
const closeRef = useRef(onClose)
|
|
110
|
-
closeRef.current = onClose
|
|
111
|
-
|
|
112
|
-
useEffect(() => {
|
|
113
|
-
if (!isOpen) return
|
|
114
|
-
const panel = ref.current
|
|
115
|
-
// `activeElement` を見るだけでは戻し先にならない。押下で焦点が乗らないブラウザ
|
|
116
|
-
// (Safari / macOS の Firefox) では body へ戻ってしまう。開いた要素を明示的に覚える
|
|
117
|
-
const opener = openerRef?.current ?? (document.activeElement as HTMLElement | null)
|
|
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()
|
|
152
|
-
|
|
153
|
-
const onKeyDown = (event: KeyboardEvent) => {
|
|
154
|
-
if (event.key === "Escape") {
|
|
155
|
-
// 変換中の Escape は「候補を取り消す」であって「閉じる」ではない。ここで
|
|
156
|
-
// 閉じると、編集中の下書きは状態ごと捨てられ、打ち直しになる
|
|
157
|
-
if (event.isComposing || event.keyCode === 229) return
|
|
158
|
-
// ⚠️ 内側の部品が既に処理した Escape で対話ごと閉じない。選択欄は「一覧を閉じる」
|
|
159
|
-
// ために Escape を消費し、`preventDefault` で消費済みだと示した上で、対話が
|
|
160
|
-
// 反応できるよう伝播は止めない。ここで既定動作の有無を見ないと、**一覧を畳む
|
|
161
|
-
// つもりの 1 打鍵で対話全体と入力内容が消える**
|
|
162
|
-
if (event.defaultPrevented) return
|
|
163
|
-
event.preventDefault()
|
|
164
|
-
closeRef.current()
|
|
165
|
-
return
|
|
166
|
-
}
|
|
167
|
-
if (event.key !== "Tab") return
|
|
168
|
-
const items = focusables()
|
|
169
|
-
if (items.length === 0) return
|
|
170
|
-
const first = items[0]
|
|
171
|
-
const last = items[items.length - 1]
|
|
172
|
-
const active = document.activeElement
|
|
173
|
-
const outside = !panel?.contains(active)
|
|
174
|
-
if (event.shiftKey && (active === first || outside)) {
|
|
175
|
-
event.preventDefault()
|
|
176
|
-
last.focus()
|
|
177
|
-
} else if (!event.shiftKey && (active === last || outside)) {
|
|
178
|
-
event.preventDefault()
|
|
179
|
-
first.focus()
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
document.addEventListener("keydown", onKeyDown)
|
|
183
|
-
return () => {
|
|
184
|
-
document.removeEventListener("keydown", onKeyDown)
|
|
185
|
-
opener?.focus?.()
|
|
186
|
-
}
|
|
187
|
-
}, [isOpen, openerRef])
|
|
188
|
-
|
|
189
|
-
return ref
|
|
190
|
-
}
|
|
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
|
-
* Turns the sync-request part of an action result into a notice, or nothing when there is nothing to say.
|
|
279
|
-
* 操作結果のうち同期要求の部分を告知文へ写す処理 (言うことが無ければ `null`)。
|
|
280
|
-
*
|
|
281
|
-
* ⚠️ `queued` は告げない。積めたことの結果は、数秒後の再検証で `最終同期` が進むことそのもの
|
|
282
|
-
* である。告げるのは **押しても何も積まれなかった** 2 つの場合だけで、黙ると押しても反応しない
|
|
283
|
-
* 画面になる。
|
|
284
|
-
*
|
|
285
|
-
* @param actionResult The fetcher's last result. 直前の操作結果。
|
|
286
|
-
* @param labels Active labels. 使用中のラベル。
|
|
287
|
-
* @returns The notice text, or `null`. 告知文、または `null`。
|
|
288
|
-
*/
|
|
289
|
-
function syncRequestNoticeOf(actionResult: unknown, labels: UserAdminLabels): string | null {
|
|
290
|
-
if (typeof actionResult !== "object" || actionResult === null) return null
|
|
291
|
-
const outcome = (actionResult as { syncRequest?: unknown }).syncRequest
|
|
292
|
-
if (outcome === "already-pending") return labels.directoryView.syncAlreadyPending
|
|
293
|
-
if (outcome === "not-queued") return labels.directoryView.linkSyncNotQueued
|
|
294
|
-
return null
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/**
|
|
298
|
-
* Indexes linked groups by both upstream keys. 連携済みグループを 2 つの上流キーで索引する処理。
|
|
299
|
-
*
|
|
300
|
-
* 安定 ID とアドレスの両方を引けるようにする。上流が改名すると、リンク行のアドレスは次の同期まで
|
|
301
|
-
* 古いままであり、アドレスだけの索引では「連携済み」を見落とす。
|
|
302
|
-
*
|
|
303
|
-
* @param groups Local groups, linked or not. ローカルグループ (連携の有無を問わない)。
|
|
304
|
-
* @returns Upstream key to the local group name holding it. 上流キー → 押さえているローカル名。
|
|
305
|
-
*/
|
|
306
|
-
function upstreamIndex(groups: readonly AuthGroup[]): Map<string, string> {
|
|
307
|
-
const index = new Map<string, string>()
|
|
308
|
-
for (const group of groups) {
|
|
309
|
-
const link = group.externalLink
|
|
310
|
-
if (!link) continue
|
|
311
|
-
index.set(normalizeUpstreamKey(link.externalId), group.name)
|
|
312
|
-
index.set(normalizeUpstreamKey(link.externalKey), group.name)
|
|
313
|
-
}
|
|
314
|
-
return index
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/** 選択欄 1 行分。`note` は選べない理由か規模を添える。 */
|
|
318
|
-
type UpstreamOption = { label: string; value: string; note: string; disabled?: boolean; entry: UserAdminCatalogGroup }
|
|
319
|
-
|
|
320
|
-
/**
|
|
321
|
-
* Renders one upstream option: the searchable label, plus why it cannot be picked.
|
|
322
|
-
* 上流の選択肢 1 行を描く処理 (検索対象のラベルと、選べない理由)。
|
|
323
|
-
*
|
|
324
|
-
* 既定の描画は `label` しか出さないため、`disabled` の理由が伝わらない。薄く見えるだけの行は、
|
|
325
|
-
* 読み込み中や壊れている行と区別できない。
|
|
326
|
-
*/
|
|
327
|
-
function renderUpstreamOption(option: Option, context: RenderOptionContext): React.ReactNode {
|
|
328
|
-
const note = typeof option.note === "string" ? option.note : ""
|
|
329
|
-
return (
|
|
330
|
-
// ⚠️ 行は幅で切り詰められる。切り詰められるのは末尾、つまりアドレス — 同名のグループを
|
|
331
|
-
// 見分ける唯一の手がかりである。全文を `title` に持たせて、少なくとも確かめられるようにする
|
|
332
|
-
<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 }}>
|
|
333
|
-
<span data-aqar-upstream-option-label style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
334
|
-
{/* 独自描画は既定の一致ハイライトを失う。同じ関数と同じ装飾クラスを使って復元する
|
|
335
|
-
(打った語がどこに当たったのか判らないと、絞り込みの手応えが消える) */}
|
|
336
|
-
{getHighlightSegments(option.label, context.query).map((segment, index) => (
|
|
337
|
-
// biome-ignore lint/suspicious/noArrayIndexKey: 分割位置そのものが同一性であり、他に安定した鍵が無い
|
|
338
|
-
<span key={index} data-aqar-match={segment.matchType === "none" ? undefined : segment.matchType} style={segment.matchType === "none" ? undefined : matchStyle[segment.matchType]}>
|
|
339
|
-
{segment.text}
|
|
340
|
-
</span>
|
|
341
|
-
))}
|
|
342
|
-
</span>
|
|
343
|
-
{note === "" ? null : (
|
|
344
|
-
<span data-aqar-upstream-option-note style={{ flexShrink: 0, fontSize: "0.72rem", opacity: 0.75 }}>
|
|
345
|
-
{note}
|
|
346
|
-
</span>
|
|
347
|
-
)}
|
|
348
|
-
</span>
|
|
349
|
-
)
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
/**
|
|
353
|
-
* The texts `@aiquants/select-box` renders itself, taken from this package's labels.
|
|
354
|
-
* 選択欄が自前で描く文言を、このパッケージのラベルから与える処理。
|
|
355
|
-
*
|
|
356
|
-
* 渡さないと件数・空状態・クリア・開閉の読み上げが英語のまま出る — 和文の配備でそこだけ英語になる。
|
|
357
|
-
*/
|
|
358
|
-
function pickerTexts(labels: UserAdminLabels): SelectBoxTexts {
|
|
359
|
-
const L = labels.directoryView
|
|
360
|
-
return {
|
|
361
|
-
noOptions: L.pickerNoOptions,
|
|
362
|
-
clear: L.pickerClear,
|
|
363
|
-
toggle: L.pickerToggle,
|
|
364
|
-
selection: (selected) => fill(L.pickerSelected, { value: selected.map((o) => o.label).join(", ") }),
|
|
365
|
-
status: (info) => fill(L.pickerAvailable, { count: info.count }),
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
/**
|
|
370
|
-
* Builds a collator that cannot crash the view. 画面を落とさない照合器を作る処理。
|
|
371
|
-
*
|
|
372
|
-
* `Intl.Collator` は BCP 47 として読めないタグに `RangeError` を投げる。並べ替えは表示だけの
|
|
373
|
-
* 関心事なのに、投げられると一覧そのものが描けなくなる。読めないタグは環境既定に倒す。
|
|
374
|
-
*
|
|
375
|
-
* @param locale Requested locale tag. 要求されたロケールタグ。
|
|
376
|
-
* @returns A comparison function for display strings. 表示文字列用の比較関数。
|
|
377
|
-
*/
|
|
378
|
-
function collatorFor(locale: string): (a: string, b: string) => number {
|
|
379
|
-
try {
|
|
380
|
-
return new Intl.Collator(locale).compare
|
|
381
|
-
} catch {
|
|
382
|
-
return new Intl.Collator().compare
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* Derives a local group key from an upstream address. 上流アドレスからローカルのグループキーを導く処理。
|
|
388
|
-
*
|
|
389
|
-
* アドレスのローカル部だけを使い、鍵に使えない文字を落とす。運用者が手で打ち直す手間を省くための
|
|
390
|
-
* **初期値** であり、確定値ではない (画面上で編集できる)。
|
|
391
|
-
*
|
|
392
|
-
* @param externalKey Upstream address. 上流のアドレス。
|
|
393
|
-
* @returns A usable group key. グループキーとして使える文字列。
|
|
394
|
-
*/
|
|
395
|
-
function groupKeyFromUpstream(externalKey: string): string {
|
|
396
|
-
const local = externalKey.split("@")[0] ?? externalKey
|
|
397
|
-
return local
|
|
398
|
-
.toLowerCase()
|
|
399
|
-
.replace(/[^a-z0-9._-]+/g, "-")
|
|
400
|
-
.replace(/^-+|-+$/g, "")
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
export function UsersView() {
|
|
404
|
-
const data = useLoaderData() as UserAdminLoaderData
|
|
405
|
-
const fetcher = useFetcher()
|
|
406
|
-
const { submit: submitThen, resetResult, result: actionResult } = useSubmitThen(fetcher)
|
|
407
|
-
const labels = data.labels ?? defaultUserAdminLabels
|
|
408
|
-
|
|
409
|
-
const [searchQuery, setSearchQuery] = useState("")
|
|
410
|
-
// 2 つの独立した状態にすると「両方開いている」状態が表現でき、エラー欄が 2 つ描かれる。
|
|
411
|
-
// 排他であることを型で持つ
|
|
412
|
-
const [modal, setModal] = useState<{ kind: "create" } | { kind: "edit"; user: AuthUser } | null>(null)
|
|
413
|
-
const editingUser = modal?.kind === "edit" ? modal.user : null
|
|
414
|
-
const isCreateOpen = modal?.kind === "create"
|
|
415
|
-
const openerRef = useRef<HTMLElement | null>(null)
|
|
416
|
-
/** ダイアログを開く。直前の操作の結果を持ち越さない (別の操作の拒否が説明文になる)。 */
|
|
417
|
-
const openModal = (next: { kind: "create" } | { kind: "edit"; user: AuthUser }, event: React.MouseEvent<HTMLElement>) => {
|
|
418
|
-
openerRef.current = event.currentTarget
|
|
419
|
-
resetResult()
|
|
420
|
-
// ⚠️ 前回の下書きを残すと、別人を追加するつもりで開いた欄に前の人の Email が入っている。
|
|
421
|
-
// 見えている表示名だけ書き換えて送ると、**別人のサインイン identity を持つ利用者** ができる
|
|
422
|
-
if (next.kind === "create") {
|
|
423
|
-
setNewDisplayName("")
|
|
424
|
-
setNewEmail("")
|
|
425
|
-
}
|
|
426
|
-
setModal(next)
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
const [newDisplayName, setNewDisplayName] = useState("")
|
|
430
|
-
const [newEmail, setNewEmail] = useState("")
|
|
431
|
-
const createUserRef = useModalBehavior(isCreateOpen, () => setModal(null), openerRef)
|
|
432
|
-
const editUserRef = useModalBehavior(editingUser !== null, () => setModal(null), openerRef)
|
|
433
|
-
|
|
434
|
-
const busy = fetcher.state !== "idle"
|
|
435
|
-
// 並べ替えは表示だけの関心事なので状態は画面に持つ。URL へ載せると共有できるが、
|
|
436
|
-
// 「同じ URL を開いたのに並びが違う」という取り違えを防ぐには loader 側の合意が要る
|
|
437
|
-
const [sort, setSort] = useState<{ column: "id" | "displayName" | "email" | "isActive"; ascending: boolean }>({ column: "displayName", ascending: true })
|
|
438
|
-
const compareText = collatorFor(labels.locale)
|
|
439
|
-
// メールクライアントやチャットから貼った値はほぼ必ず末尾に空白を伴い、IME は全角空白を作る。
|
|
440
|
-
// trim しないと、存在する利用者が「登録が無い」ように見える
|
|
441
|
-
const userNeedle = searchQuery.trim().toLowerCase()
|
|
442
|
-
const filteredUsers = data.users
|
|
443
|
-
.filter((u) => u.displayName.toLowerCase().includes(userNeedle) || u.email.toLowerCase().includes(userNeedle))
|
|
444
|
-
.slice()
|
|
445
|
-
.sort((a, b) => {
|
|
446
|
-
const direction = sort.ascending ? 1 : -1
|
|
447
|
-
if (sort.column === "id") return (a.id - b.id) * direction
|
|
448
|
-
if (sort.column === "isActive") return (Number(a.isActive) - Number(b.isActive)) * direction
|
|
449
|
-
// 表示名と Email は言語に依存する。ロケールを固定して、環境ごとに並びが変わるのを防ぐ
|
|
450
|
-
return compareText(a[sort.column], b[sort.column]) * direction
|
|
451
|
-
})
|
|
452
|
-
|
|
453
|
-
/** 同じ列を押したら向きだけ変える。別の列なら昇順から始める。 */
|
|
454
|
-
const toggleSort = (column: typeof sort.column) => {
|
|
455
|
-
setSort((current) => (current.column === column ? { column, ascending: !current.ascending } : { column, ascending: true }))
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
const handleToggleActive = (user: AuthUser) => {
|
|
459
|
-
// ⚠️ `aria-disabled` は焦点を残すために `disabled` の代わりに使っている。押せてしまう
|
|
460
|
-
// ぶん、実際に断るのはここである
|
|
461
|
-
if (busy) return
|
|
462
|
-
// 無効化はサインインを止める。有効化は元に戻すだけなので確認しない
|
|
463
|
-
if (user.isActive && !window.confirm(fill(labels.usersView.confirmDeactivate, { user: user.displayName }))) return
|
|
464
|
-
submitThen({ intent: "toggleUserActive", userId: String(user.id), isActive: String(!user.isActive) })
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
const handleCreateUser = (e: React.FormEvent) => {
|
|
468
|
-
e.preventDefault()
|
|
469
|
-
if (!(newDisplayName && newEmail)) return
|
|
470
|
-
// 送信した値と今の値が違うなら、往復の間に打ち込まれたものである。消してはならない
|
|
471
|
-
const sentName = newDisplayName
|
|
472
|
-
const sentEmail = newEmail
|
|
473
|
-
submitThen({ intent: "createUser", displayName: sentName, email: sentEmail }, () => {
|
|
474
|
-
setNewDisplayName((current) => (current === sentName ? "" : current))
|
|
475
|
-
setNewEmail((current) => (current === sentEmail ? "" : current))
|
|
476
|
-
setModal(null)
|
|
477
|
-
})
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
const handleUpdateUser = (e: React.FormEvent) => {
|
|
481
|
-
e.preventDefault()
|
|
482
|
-
if (!editingUser) return
|
|
483
|
-
submitThen({ intent: "updateUser", userId: String(editingUser.id), displayName: editingUser.displayName, email: editingUser.email }, () => setModal(null))
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
return (
|
|
487
|
-
<section data-testid="users-view-section" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
|
488
|
-
<h2 style={heading}>{labels.usersView.title}</h2>
|
|
489
|
-
{/* モーダルが開いている間はモーダル内に描く。両方描くと同じ hook が 2 つになる */}
|
|
490
|
-
{!modal && <ActionError result={actionResult} />}
|
|
491
|
-
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", backgroundColor: "#ffffff", padding: "1rem", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
|
|
492
|
-
<input
|
|
493
|
-
type="text"
|
|
494
|
-
aria-label={labels.usersView.searchLabel}
|
|
495
|
-
placeholder={labels.usersView.searchPlaceholder}
|
|
496
|
-
value={searchQuery}
|
|
497
|
-
onChange={(e) => setSearchQuery(e.target.value)}
|
|
498
|
-
data-testid="users-search-input"
|
|
499
|
-
style={{ padding: "6px 12px", borderRadius: "6px", border: "1px solid #cbd5e1", fontSize: "0.85rem", width: "260px" }}
|
|
500
|
-
/>
|
|
501
|
-
<button
|
|
502
|
-
type="button"
|
|
503
|
-
onClick={(event) => openModal({ kind: "create" }, event)}
|
|
504
|
-
data-testid="users-add-button"
|
|
505
|
-
style={{ padding: "6px 14px", borderRadius: "6px", backgroundColor: "#0284c7", color: "#ffffff", border: "none", fontSize: "0.85rem", fontWeight: 600, cursor: "pointer" }}>
|
|
506
|
-
{labels.usersView.addUser}
|
|
507
|
-
</button>
|
|
508
|
-
</div>
|
|
509
|
-
|
|
510
|
-
{/* 絞り込みの結果は視覚的にしか変わらない。読み上げへ届けるには live region が要る —
|
|
511
|
-
無いと、打ち込むたびに何件残ったのか判らないまま表全体を辿ることになる */}
|
|
512
|
-
<p data-testid="users-summary" role="status" aria-live="polite" style={{ margin: 0, fontSize: "0.78rem", color: "#475569" }}>
|
|
513
|
-
{fill(labels.usersView.listSummary, { shown: filteredUsers.length, total: data.users.length })}
|
|
514
|
-
</p>
|
|
515
|
-
|
|
516
|
-
<div style={{ backgroundColor: "#ffffff", borderRadius: "8px", border: "1px solid #e2e8f0", overflow: "hidden" }}>
|
|
517
|
-
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.85rem", textAlign: "left" }}>
|
|
518
|
-
<thead>
|
|
519
|
-
<tr style={{ backgroundColor: "#f8fafc", borderBottom: "1px solid #e2e8f0", color: "#475569" }}>
|
|
520
|
-
{(
|
|
521
|
-
[
|
|
522
|
-
["id", labels.common.id],
|
|
523
|
-
["displayName", labels.usersView.displayName],
|
|
524
|
-
["email", labels.usersView.email],
|
|
525
|
-
["isActive", labels.common.status],
|
|
526
|
-
] as const
|
|
527
|
-
).map(([column, heading]) => (
|
|
528
|
-
<th key={column} scope="col" style={{ padding: 0 }} aria-sort={sort.column === column ? (sort.ascending ? "ascending" : "descending") : "none"}>
|
|
529
|
-
<button
|
|
530
|
-
type="button"
|
|
531
|
-
data-testid={`users-sort-${column}`}
|
|
532
|
-
onClick={() => toggleSort(column)}
|
|
533
|
-
// 向きを名前に含める。含めないと、押しても押し返しても同じ
|
|
534
|
-
// 「Sort by 名前」としか読まれず、今どちらなのか判らない
|
|
535
|
-
aria-label={fill(sort.column === column ? (sort.ascending ? labels.usersView.sortedAscending : labels.usersView.sortedDescending) : labels.usersView.sortBy, { column: heading })}
|
|
536
|
-
style={{ width: "100%", padding: "10px 14px", textAlign: "left", background: "transparent", border: "none", cursor: "pointer", font: "inherit", color: "inherit", fontWeight: sort.column === column ? 700 : 500 }}>
|
|
537
|
-
{heading}
|
|
538
|
-
{/* 向きは記号だけに頼らない。読み上げは aria-sort が担う */}
|
|
539
|
-
{/* 使っていない列に ▲ を薄く出すと「昇順で並んでいる」と読めてしまう。
|
|
540
|
-
並べ替えていない列は向きを持たない記号を出す */}
|
|
541
|
-
<span aria-hidden="true" style={{ marginLeft: "4px", opacity: sort.column === column ? 1 : 0.35 }}>
|
|
542
|
-
{sort.column === column ? (sort.ascending ? "▲" : "▼") : "⇅"}
|
|
543
|
-
</span>
|
|
544
|
-
</button>
|
|
545
|
-
</th>
|
|
546
|
-
))}
|
|
547
|
-
<th scope="col" style={{ padding: "10px 14px", textAlign: "right" }}>
|
|
548
|
-
{labels.common.actions}
|
|
549
|
-
</th>
|
|
550
|
-
</tr>
|
|
551
|
-
</thead>
|
|
552
|
-
<tbody>
|
|
553
|
-
{filteredUsers.map((u) => (
|
|
554
|
-
<tr key={u.id} style={{ borderBottom: "1px solid #f1f5f9" }} data-testid={`user-row-${u.id}`}>
|
|
555
|
-
<td style={{ padding: "10px 14px", color: "#475569" }}>{fill(labels.common.idTemplate, { id: u.id })}</td>
|
|
556
|
-
<td style={{ padding: "10px 14px", fontWeight: 600, color: "#0f172a" }}>{u.displayName}</td>
|
|
557
|
-
<td style={{ padding: "10px 14px", color: "#475569" }}>{u.email}</td>
|
|
558
|
-
<td style={{ padding: "10px 14px" }}>
|
|
559
|
-
<span style={{ fontSize: "0.75rem", padding: "2px 8px", borderRadius: "10px", backgroundColor: u.isActive ? "#dcfce7" : "#fee2e2", color: u.isActive ? "#15803d" : "#b91c1c", fontWeight: 600 }}>
|
|
560
|
-
{u.isActive ? labels.usersView.active : labels.usersView.inactive}
|
|
561
|
-
</span>
|
|
562
|
-
</td>
|
|
563
|
-
<td style={{ padding: "10px 14px", textAlign: "right" }}>
|
|
564
|
-
<button
|
|
565
|
-
type="button"
|
|
566
|
-
onClick={(event) => openModal({ kind: "edit", user: u }, event)}
|
|
567
|
-
aria-label={fill(labels.usersView.editUserAria, { user: u.displayName })}
|
|
568
|
-
data-testid={`edit-user-btn-${u.id}`}
|
|
569
|
-
style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", fontSize: "0.78rem", cursor: "pointer", marginRight: "6px" }}>
|
|
570
|
-
{labels.common.edit}
|
|
571
|
-
</button>
|
|
572
|
-
<button
|
|
573
|
-
type="button"
|
|
574
|
-
onClick={() => handleToggleActive(u)}
|
|
575
|
-
{...rowActionProps(busy)}
|
|
576
|
-
aria-label={fill(u.isActive ? labels.usersView.deactivateAria : labels.usersView.activateAria, { user: u.displayName })}
|
|
577
|
-
data-testid={`toggle-user-btn-${u.id}`}
|
|
578
|
-
style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", fontSize: "0.78rem", cursor: busy ? "default" : "pointer", color: u.isActive ? "#b91c1c" : "#15803d" }}>
|
|
579
|
-
{u.isActive ? labels.usersView.deactivate : labels.usersView.activate}
|
|
580
|
-
</button>
|
|
581
|
-
</td>
|
|
582
|
-
</tr>
|
|
583
|
-
))}
|
|
584
|
-
{filteredUsers.length === 0 && (
|
|
585
|
-
<tr>
|
|
586
|
-
<td colSpan={5} style={{ padding: "2rem", textAlign: "center", color: "#475569" }}>
|
|
587
|
-
{/* 「検索で 0 件」と「そもそも登録が無い」は別の事態である。同じ文言に
|
|
588
|
-
すると、新規テナントを開いた運用者は一覧が壊れていると読む */}
|
|
589
|
-
{userNeedle === "" ? labels.usersView.empty : labels.usersView.emptyFiltered}
|
|
590
|
-
</td>
|
|
591
|
-
</tr>
|
|
592
|
-
)}
|
|
593
|
-
</tbody>
|
|
594
|
-
</table>
|
|
595
|
-
</div>
|
|
596
|
-
|
|
597
|
-
{/* Create Modal */}
|
|
598
|
-
{isCreateOpen && (
|
|
599
|
-
<div style={{ position: "fixed", inset: 0, backgroundColor: "rgba(0,0,0,0.4)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 100 }}>
|
|
600
|
-
<form
|
|
601
|
-
ref={createUserRef}
|
|
602
|
-
onSubmit={handleCreateUser}
|
|
603
|
-
role="dialog"
|
|
604
|
-
aria-modal="true"
|
|
605
|
-
aria-labelledby="create-user-title"
|
|
606
|
-
{...(hasError(actionResult) ? { "aria-describedby": "create-user-error" } : {})}
|
|
607
|
-
data-testid="user-create-modal"
|
|
608
|
-
style={{ backgroundColor: "#ffffff", padding: "1.5rem", borderRadius: "8px", width: "360px", boxShadow: "0 10px 25px rgba(0,0,0,0.15)" }}>
|
|
609
|
-
<h3 id="create-user-title" style={{ margin: "0 0 1rem 0", fontSize: "1.1rem" }}>
|
|
610
|
-
{labels.usersView.addUser}
|
|
611
|
-
</h3>
|
|
612
|
-
<ActionError result={actionResult} id="create-user-error" />
|
|
613
|
-
<div style={{ marginBottom: "1rem" }}>
|
|
614
|
-
<label htmlFor="create-user-display-name-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
|
|
615
|
-
{labels.usersView.displayName}
|
|
616
|
-
</label>
|
|
617
|
-
<input
|
|
618
|
-
id="create-user-display-name-input"
|
|
619
|
-
type="text"
|
|
620
|
-
value={newDisplayName}
|
|
621
|
-
onChange={(e) => setNewDisplayName(e.target.value)}
|
|
622
|
-
required
|
|
623
|
-
data-testid="create-user-display-name"
|
|
624
|
-
style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box" }}
|
|
625
|
-
/>
|
|
626
|
-
</div>
|
|
627
|
-
<div style={{ marginBottom: "1.2rem" }}>
|
|
628
|
-
<label htmlFor="create-user-email-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
|
|
629
|
-
{labels.usersView.email}
|
|
630
|
-
</label>
|
|
631
|
-
<input
|
|
632
|
-
id="create-user-email-input"
|
|
633
|
-
type="email"
|
|
634
|
-
value={newEmail}
|
|
635
|
-
onChange={(e) => setNewEmail(e.target.value)}
|
|
636
|
-
required
|
|
637
|
-
data-testid="create-user-email"
|
|
638
|
-
style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box" }}
|
|
639
|
-
/>
|
|
640
|
-
</div>
|
|
641
|
-
<div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
|
|
642
|
-
<button type="button" onClick={() => setModal(null)} data-testid="cancel-create-user" style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
|
|
643
|
-
{labels.common.cancel}
|
|
644
|
-
</button>
|
|
645
|
-
<button type="submit" data-testid="submit-create-user" disabled={busy} aria-busy={busy} style={{ padding: "6px 12px", borderRadius: "4px", backgroundColor: "#0284c7", color: "#ffffff", border: "none", cursor: "pointer" }}>
|
|
646
|
-
{labels.common.create}
|
|
647
|
-
</button>
|
|
648
|
-
</div>
|
|
649
|
-
</form>
|
|
650
|
-
</div>
|
|
651
|
-
)}
|
|
652
|
-
|
|
653
|
-
{/* Edit Modal */}
|
|
654
|
-
{editingUser && (
|
|
655
|
-
<div style={{ position: "fixed", inset: 0, backgroundColor: "rgba(0,0,0,0.4)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 100 }}>
|
|
656
|
-
<form
|
|
657
|
-
ref={editUserRef}
|
|
658
|
-
onSubmit={handleUpdateUser}
|
|
659
|
-
role="dialog"
|
|
660
|
-
aria-modal="true"
|
|
661
|
-
aria-labelledby="edit-user-title"
|
|
662
|
-
{...(hasError(actionResult) ? { "aria-describedby": "edit-user-error" } : {})}
|
|
663
|
-
data-testid="user-edit-modal"
|
|
664
|
-
style={{ backgroundColor: "#ffffff", padding: "1.5rem", borderRadius: "8px", width: "360px", boxShadow: "0 10px 25px rgba(0,0,0,0.15)" }}>
|
|
665
|
-
<h3 id="edit-user-title" style={{ margin: "0 0 1rem 0", fontSize: "1.1rem" }}>
|
|
666
|
-
{labels.usersView.editUser}
|
|
667
|
-
</h3>
|
|
668
|
-
<ActionError result={actionResult} id="edit-user-error" />
|
|
669
|
-
<div style={{ marginBottom: "1rem" }}>
|
|
670
|
-
<label htmlFor="edit-user-display-name-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
|
|
671
|
-
{labels.usersView.displayName}
|
|
672
|
-
</label>
|
|
673
|
-
<input
|
|
674
|
-
id="edit-user-display-name-input"
|
|
675
|
-
type="text"
|
|
676
|
-
value={editingUser.displayName}
|
|
677
|
-
onChange={(e) => setModal({ kind: "edit", user: { ...editingUser, displayName: e.target.value } })}
|
|
678
|
-
required
|
|
679
|
-
data-testid="edit-user-display-name"
|
|
680
|
-
style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box" }}
|
|
681
|
-
/>
|
|
682
|
-
</div>
|
|
683
|
-
<div style={{ marginBottom: "1.2rem" }}>
|
|
684
|
-
<label htmlFor="edit-user-email-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
|
|
685
|
-
{labels.usersView.email}
|
|
686
|
-
</label>
|
|
687
|
-
<input
|
|
688
|
-
id="edit-user-email-input"
|
|
689
|
-
type="email"
|
|
690
|
-
value={editingUser.email}
|
|
691
|
-
onChange={(e) => setModal({ kind: "edit", user: { ...editingUser, email: e.target.value } })}
|
|
692
|
-
required
|
|
693
|
-
data-testid="edit-user-email"
|
|
694
|
-
style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box" }}
|
|
695
|
-
/>
|
|
696
|
-
</div>
|
|
697
|
-
<div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
|
|
698
|
-
<button type="button" onClick={() => setModal(null)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
|
|
699
|
-
{labels.common.cancel}
|
|
700
|
-
</button>
|
|
701
|
-
<button type="submit" data-testid="submit-edit-user" disabled={busy} aria-busy={busy} style={{ padding: "6px 12px", borderRadius: "4px", backgroundColor: "#0284c7", color: "#ffffff", border: "none", cursor: "pointer" }}>
|
|
702
|
-
{labels.common.save}
|
|
703
|
-
</button>
|
|
704
|
-
</div>
|
|
705
|
-
</form>
|
|
706
|
-
</div>
|
|
707
|
-
)}
|
|
708
|
-
</section>
|
|
709
|
-
)
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
/**
|
|
713
|
-
* View for managing user groups (create, edit, delete).
|
|
714
|
-
* ユーザーグループ管理ビュー (作成・編集・削除)。
|
|
715
|
-
*/
|
|
716
|
-
export function GroupsView() {
|
|
717
|
-
const data = useLoaderData() as UserAdminLoaderData
|
|
718
|
-
const fetcher = useFetcher()
|
|
719
|
-
const { submit: submitThen, resetResult, result: actionResult } = useSubmitThen(fetcher)
|
|
720
|
-
const labels = data.labels ?? defaultUserAdminLabels
|
|
721
|
-
const syncRequestNotice = syncRequestNoticeOf(actionResult, labels)
|
|
722
|
-
|
|
723
|
-
const [searchQuery, setSearchQuery] = useState("")
|
|
724
|
-
const [modal, setModal] = useState<{ kind: "create" } | { kind: "edit"; group: AuthGroup } | null>(null)
|
|
725
|
-
const editingGroup = modal?.kind === "edit" ? modal.group : null
|
|
726
|
-
const isCreateOpen = modal?.kind === "create"
|
|
727
|
-
const openerRef = useRef<HTMLElement | null>(null)
|
|
728
|
-
/** ダイアログを開く。直前の操作の結果と入力を持ち越さない。 */
|
|
729
|
-
const openModal = (next: { kind: "create" } | { kind: "edit"; group: AuthGroup }, event: React.MouseEvent<HTMLElement>) => {
|
|
730
|
-
openerRef.current = event.currentTarget
|
|
731
|
-
resetResult()
|
|
732
|
-
// 前回の入力を残すと、上流から作った直後に開いた作成欄が別グループの名前で埋まっている。
|
|
733
|
-
// 「連携済みなのに、もう 1 つ作ろうとしている」ように見え、取り違えたまま送られる
|
|
734
|
-
if (next.kind === "create") resetCreateFields()
|
|
735
|
-
setModal(next)
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
const busy = fetcher.state !== "idle"
|
|
739
|
-
const [newGroupKey, setNewGroupKey] = useState("")
|
|
740
|
-
const [newName, setNewName] = useState("")
|
|
741
|
-
const [newDescription, setNewDescription] = useState("")
|
|
742
|
-
// 上流から作る経路は、候補一覧が引けているときだけ選べる
|
|
743
|
-
const [createMode, setCreateMode] = useState<"local" | "upstream">("local")
|
|
744
|
-
/**
|
|
745
|
-
* 焦点が当たっている作成経路。
|
|
746
|
-
*
|
|
747
|
-
* ⚠️ ラジオ群の実体は視覚的に隠してある (装飾はラベル側の押しボタン風の見た目)。隠した入力へ
|
|
748
|
-
* 焦点が来ると、ブラウザは 1px の箱に焦点環を描き、操作者からは「どこにも焦点が無い」ように
|
|
749
|
-
* 見える。インラインの style では `:focus-within` を書けないため、焦点の在処を状態で持って
|
|
750
|
-
* ラベルへ環を描く
|
|
751
|
-
*/
|
|
752
|
-
const [focusedMode, setFocusedMode] = useState<"local" | "upstream" | null>(null)
|
|
753
|
-
const [upstream, setUpstream] = useState<UserAdminCatalogGroup | null>(null)
|
|
754
|
-
const [membershipMode, setMembershipMode] = useState<AuthDirectoryMembershipMode>("direct")
|
|
755
|
-
/**
|
|
756
|
-
* 上流から埋めた 3 欄の控え。ローカルへ戻すときに「こちらが埋めた分だけ」を消すために持つ。
|
|
757
|
-
* 一律に消すと、先に手で打った内容まで失う
|
|
758
|
-
*/
|
|
759
|
-
const prefilled = useRef<{ key: string; name: string; description: string } | null>(null)
|
|
760
|
-
// ⚠️ 候補の作り直しは描画ごとに行わない。新しい配列を毎回渡すと、選択欄は同一性で
|
|
761
|
-
// 記憶しているため、**無関係な欄の 1 打鍵ごとに候補全件を再正規化し、あいまい検索の
|
|
762
|
-
// 索引まで組み直す**。候補 4000 件で 1 打鍵 22ms を実測した (実ブラウザではさらに重い)
|
|
763
|
-
// ⚠️ 所属件数はグループごとに数え直さない。カード 1 枚ごとに所属配列を全走査すると
|
|
764
|
-
// O(グループ数 × 所属数) になり、検索欄の 1 打鍵ごとにそれを繰り返す。1 回だけ畳んでおく
|
|
765
|
-
const memberCountByGroup = useMemo(() => {
|
|
766
|
-
const counts = new Map<number, number>()
|
|
767
|
-
for (const m of data.groupMembers) counts.set(m.groupId, (counts.get(m.groupId) ?? 0) + 1)
|
|
768
|
-
return counts
|
|
769
|
-
}, [data.groupMembers])
|
|
770
|
-
const linkedTo = useMemo(() => upstreamIndex(data.groups), [data.groups])
|
|
771
|
-
const options = useMemo(() => upstreamOptions(data.catalog, linkedTo, labels), [data.catalog, linkedTo, labels])
|
|
772
|
-
// ⚠️ 判定に使うのは **選べる候補の数** である。行数で判定すると、全件が連携済みの配備で
|
|
773
|
-
// 「すべて選べない選択欄」と「押せない作成ボタン」を出し、hint は「まず候補を選べ」と
|
|
774
|
-
// 言う — 従いようのない指示になる
|
|
775
|
-
const selectableUpstreamCount = useMemo(() => options.filter((o) => o.disabled !== true).length, [options])
|
|
776
|
-
// 候補が引けても、この配備がどのプロバイダとして連携するかが決まっていなければ連携は作れない。
|
|
777
|
-
// 送れば `provider: ""` の行が残り、同期ジョブは「別プロバイダ」として恒久的に無視する
|
|
778
|
-
// ⚠️ 候補が 0 件の一覧で経路を出すと、選べない選択欄と押せない作成ボタンという終端へ導く。
|
|
779
|
-
// 「読めたが 1 件も無い」は上流側の話であり、この面で解決できることは何も無い
|
|
780
|
-
const upstreamPickable = data.catalog.kind === "ready" && data.catalog.groups.length > 0 && data.directoryStatus.provider !== ""
|
|
781
|
-
const createGroupRef = useModalBehavior(isCreateOpen, () => setModal(null), openerRef)
|
|
782
|
-
const editGroupRef = useModalBehavior(editingGroup !== null, () => setModal(null), openerRef)
|
|
783
|
-
|
|
784
|
-
const groupNeedle = searchQuery.trim().toLowerCase()
|
|
785
|
-
const filteredGroups = data.groups.filter((g) => g.name.toLowerCase().includes(groupNeedle) || g.groupKey.toLowerCase().includes(groupNeedle))
|
|
786
|
-
|
|
787
|
-
/** 作成欄をすべて初期状態へ戻す。 */
|
|
788
|
-
const resetCreateFields = () => {
|
|
789
|
-
setNewGroupKey("")
|
|
790
|
-
setNewName("")
|
|
791
|
-
setNewDescription("")
|
|
792
|
-
setCreateMode("local")
|
|
793
|
-
setUpstream(null)
|
|
794
|
-
setMembershipMode("direct")
|
|
795
|
-
prefilled.current = null
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
const handleCreateGroup = (e: React.FormEvent) => {
|
|
799
|
-
e.preventDefault()
|
|
800
|
-
if (!(newGroupKey && newName)) return
|
|
801
|
-
if (createMode === "upstream" && upstream === null) return
|
|
802
|
-
// 連携付きで作ると、名前・説明・メンバー・削除のすべてが上流の管理下へ移る。取り消すには
|
|
803
|
-
// 連携解除という別の操作が要るため、押した先が戻れない操作であることを先に伝える
|
|
804
|
-
if (createMode === "upstream" && upstream !== null && !window.confirm(fill(labels.groupsView.confirmCreateLinked, { group: newName, upstream: upstream.externalKey }))) return
|
|
805
|
-
// 送信した値と今の値が違うなら、往復の間に打ち込まれたものである。消してはならない
|
|
806
|
-
const sent = { key: newGroupKey, name: newName, description: newDescription }
|
|
807
|
-
const clear = () => {
|
|
808
|
-
setNewGroupKey((current) => (current === sent.key ? "" : current))
|
|
809
|
-
setNewName((current) => (current === sent.name ? "" : current))
|
|
810
|
-
setNewDescription((current) => (current === sent.description ? "" : current))
|
|
811
|
-
setUpstream(null)
|
|
812
|
-
setModal(null)
|
|
813
|
-
}
|
|
814
|
-
if (createMode === "upstream" && upstream !== null) {
|
|
815
|
-
// 作成と連携は 1 つの intent で行う。2 回に分けると、片方だけ成功した状態が生まれる
|
|
816
|
-
submitThen({ intent: "createLinkedGroup", groupKey: sent.key, name: sent.name, description: sent.description, provider: data.directoryStatus.provider, externalKey: upstream.externalKey, membershipMode }, clear)
|
|
817
|
-
return
|
|
818
|
-
}
|
|
819
|
-
submitThen({ intent: "createGroup", groupKey: sent.key, name: sent.name, description: sent.description }, clear)
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
/** 上流グループを選んだら、名前と鍵の初期値を埋める (打ち直しの手間を省く)。 */
|
|
823
|
-
const chooseUpstream = (entry: UserAdminCatalogGroup | null) => {
|
|
824
|
-
setUpstream(entry)
|
|
825
|
-
if (!entry) return
|
|
826
|
-
const filled = { key: groupKeyFromUpstream(entry.externalKey), name: entry.name, description: entry.description ?? "" }
|
|
827
|
-
setNewName(filled.name)
|
|
828
|
-
setNewGroupKey(filled.key)
|
|
829
|
-
setNewDescription(filled.description)
|
|
830
|
-
prefilled.current = filled
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
/**
|
|
834
|
-
* 作成の経路を切り替える。ローカルへ戻すときは、上流から埋めた欄だけを空へ戻す。
|
|
835
|
-
*
|
|
836
|
-
* 残すと、ローカル作成のはずが上流グループの名前と説明で作られる。一律に消すと、先に
|
|
837
|
-
* 手で打った内容まで失う
|
|
838
|
-
*/
|
|
839
|
-
const switchCreateMode = (mode: "local" | "upstream") => {
|
|
840
|
-
setCreateMode(mode)
|
|
841
|
-
if (mode === "upstream") return
|
|
842
|
-
setUpstream(null)
|
|
843
|
-
const filled = prefilled.current
|
|
844
|
-
if (!filled) return
|
|
845
|
-
setNewGroupKey((current) => (current === filled.key ? "" : current))
|
|
846
|
-
setNewName((current) => (current === filled.name ? "" : current))
|
|
847
|
-
setNewDescription((current) => (current === filled.description ? "" : current))
|
|
848
|
-
prefilled.current = null
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
const handleUpdateGroup = (e: React.FormEvent) => {
|
|
852
|
-
e.preventDefault()
|
|
853
|
-
if (!editingGroup) return
|
|
854
|
-
submitThen({ intent: "updateGroup", groupId: String(editingGroup.id), name: editingGroup.name, description: editingGroup.description ?? "" }, () => setModal(null))
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
const handleDeleteGroup = (group: AuthGroup) => {
|
|
858
|
-
// ⚠️ `aria-disabled` は焦点を残すために `disabled` の代わりに使っている。押せてしまう
|
|
859
|
-
// ぶん、実際に断るのはここである
|
|
860
|
-
if (busy) return
|
|
861
|
-
// ⚠️ 名前も失うものも述べる。この面で最も破壊的な操作でありながら、確認が最も薄かった。
|
|
862
|
-
// 一覧の中から誤って隣のカードを押しても、対話の中に気付ける手がかりが無かった
|
|
863
|
-
const memberCount = data.groupMembers.filter((m) => m.groupId === group.id).length
|
|
864
|
-
if (!window.confirm(fill(labels.groupsView.confirmDelete, { group: group.name, count: memberCount }))) return
|
|
865
|
-
submitThen({ intent: "deleteGroup", groupId: String(group.id) })
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
return (
|
|
869
|
-
<section data-testid="groups-view-section" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
|
870
|
-
<h2 style={heading}>{labels.groupsView.title}</h2>
|
|
871
|
-
{!modal && <ActionError result={actionResult} />}
|
|
872
|
-
{/* ⚠️ ライブリージョンは **常に置いておき、中身だけ入れ替える** (連携面と同じ理由)。
|
|
873
|
-
上流から作った直後に要求が積めなかったことを、閉じた対話の外で伝える唯一の場所 */}
|
|
874
|
-
<p
|
|
875
|
-
data-testid="groups-sync-request-notice"
|
|
876
|
-
role="status"
|
|
877
|
-
aria-live="polite"
|
|
878
|
-
hidden={syncRequestNotice === null}
|
|
879
|
-
style={syncRequestNotice === null ? undefined : { margin: 0, padding: "0.5rem 0.7rem", borderRadius: "6px", backgroundColor: "#fffbeb", border: "1px solid #fde68a", color: "#92400e", fontSize: "0.8rem" }}>
|
|
880
|
-
{syncRequestNotice}
|
|
881
|
-
</p>
|
|
882
|
-
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", backgroundColor: "#ffffff", padding: "1rem", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
|
|
883
|
-
<input
|
|
884
|
-
type="text"
|
|
885
|
-
aria-label={labels.groupsView.searchLabel}
|
|
886
|
-
placeholder={labels.groupsView.searchPlaceholder}
|
|
887
|
-
value={searchQuery}
|
|
888
|
-
onChange={(e) => setSearchQuery(e.target.value)}
|
|
889
|
-
data-testid="groups-search-input"
|
|
890
|
-
style={{ padding: "6px 12px", borderRadius: "6px", border: "1px solid #cbd5e1", fontSize: "0.85rem", width: "260px" }}
|
|
891
|
-
/>
|
|
892
|
-
<button
|
|
893
|
-
type="button"
|
|
894
|
-
onClick={(event) => openModal({ kind: "create" }, event)}
|
|
895
|
-
data-testid="groups-add-button"
|
|
896
|
-
style={{ padding: "6px 14px", borderRadius: "6px", backgroundColor: "#4338ca", color: "#ffffff", border: "none", fontSize: "0.85rem", fontWeight: 600, cursor: "pointer" }}>
|
|
897
|
-
{labels.groupsView.addGroup}
|
|
898
|
-
</button>
|
|
899
|
-
</div>
|
|
900
|
-
|
|
901
|
-
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: "1rem" }}>
|
|
902
|
-
{filteredGroups.map((g) => {
|
|
903
|
-
const memberCount = memberCountByGroup.get(g.id) ?? 0
|
|
904
|
-
return (
|
|
905
|
-
<div
|
|
906
|
-
key={g.id}
|
|
907
|
-
style={{ backgroundColor: "#ffffff", borderRadius: "8px", border: "1px solid #e2e8f0", padding: "1rem", display: "flex", flexDirection: "column", justifyContent: "space-between" }}
|
|
908
|
-
data-testid={`group-card-${g.id}`}
|
|
909
|
-
data-group-readonly={g.readOnly ? "true" : "false"}>
|
|
910
|
-
<div>
|
|
911
|
-
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "0.5rem" }}>
|
|
912
|
-
<h3 style={{ margin: 0, fontSize: "1rem", color: "#0f172a" }}>{g.name}</h3>
|
|
913
|
-
<span style={{ fontSize: "0.72rem", backgroundColor: "#e0e7ff", color: "#4338ca", padding: "2px 6px", borderRadius: "4px", fontFamily: "monospace" }}>{g.groupKey}</span>
|
|
914
|
-
</div>
|
|
915
|
-
<p style={{ margin: "0 0 0.8rem 0", fontSize: "0.82rem", color: "#64748b", minHeight: "2.4em" }}>{g.description || labels.groupsView.noDescription}</p>
|
|
916
|
-
{/* 所属も名称も上流が所有していることを、操作を試す前に伝える */}
|
|
917
|
-
{g.externalLink && (
|
|
918
|
-
<div data-testid={`group-source-${g.id}`} style={{ display: "flex", alignItems: "center", gap: "6px", flexWrap: "wrap", marginBottom: "0.6rem" }}>
|
|
919
|
-
<span style={{ fontSize: "0.72rem", padding: "2px 8px", borderRadius: "10px", backgroundColor: "#ede9fe", color: "#6d28d9", fontWeight: 600 }}>{labels.common.managedUpstream}</span>
|
|
920
|
-
<span style={{ fontSize: "0.72rem", fontFamily: "monospace", color: "#64748b" }}>{g.externalLink.externalKey}</span>
|
|
921
|
-
{g.externalLink.unresolvedCount > 0 && (
|
|
922
|
-
<span data-testid={`group-unresolved-${g.id}`} style={{ fontSize: "0.72rem", color: "#b45309" }}>
|
|
923
|
-
{fill(labels.directoryView.unresolvedTemplate, { count: g.externalLink.unresolvedCount })}
|
|
924
|
-
</span>
|
|
925
|
-
)}
|
|
926
|
-
</div>
|
|
927
|
-
)}
|
|
928
|
-
</div>
|
|
929
|
-
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", borderTop: "1px solid #f1f5f9", paddingTop: "0.6rem" }}>
|
|
930
|
-
<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" }}>
|
|
931
|
-
{fill(labels.groupsView.memberCountTemplate, { count: memberCount })}
|
|
932
|
-
</Link>
|
|
933
|
-
{/* 上流が所有する行では操作を **描かない**。無効化したボタンを出すと、
|
|
934
|
-
押せる操作と見分けがつかず、押せない理由も伝わらない */}
|
|
935
|
-
{g.readOnly ? (
|
|
936
|
-
<span data-testid={`readonly-group-${g.id}`} style={{ fontSize: "0.75rem", color: "#475569" }}>
|
|
937
|
-
{labels.common.readOnly}
|
|
938
|
-
</span>
|
|
939
|
-
) : (
|
|
940
|
-
<div>
|
|
941
|
-
<button
|
|
942
|
-
type="button"
|
|
943
|
-
onClick={(event) => openModal({ kind: "edit", group: g }, event)}
|
|
944
|
-
{...rowActionProps(busy)}
|
|
945
|
-
aria-label={fill(labels.groupsView.editGroupAria, { group: g.name })}
|
|
946
|
-
data-testid={`edit-group-btn-${g.id}`}
|
|
947
|
-
style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", fontSize: "0.75rem", cursor: "pointer", marginRight: "4px" }}>
|
|
948
|
-
{labels.common.edit}
|
|
949
|
-
</button>
|
|
950
|
-
<button
|
|
951
|
-
type="button"
|
|
952
|
-
onClick={() => handleDeleteGroup(g)}
|
|
953
|
-
{...rowActionProps(busy)}
|
|
954
|
-
aria-label={fill(labels.groupsView.deleteGroupAria, { group: g.name })}
|
|
955
|
-
data-testid={`delete-group-btn-${g.id}`}
|
|
956
|
-
style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #fee2e2", backgroundColor: "#fff5f5", color: "#b91c1c", fontSize: "0.75rem", cursor: "pointer" }}>
|
|
957
|
-
{labels.common.delete}
|
|
958
|
-
</button>
|
|
959
|
-
</div>
|
|
960
|
-
)}
|
|
961
|
-
</div>
|
|
962
|
-
</div>
|
|
963
|
-
)
|
|
964
|
-
})}
|
|
965
|
-
</div>
|
|
966
|
-
{filteredGroups.length === 0 && (
|
|
967
|
-
<div data-testid="groups-empty" style={{ padding: "2rem", textAlign: "center", color: "#475569", backgroundColor: "#ffffff", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
|
|
968
|
-
{data.groups.length === 0 ? labels.groupsView.empty : labels.groupsView.emptyFiltered}
|
|
969
|
-
</div>
|
|
970
|
-
)}
|
|
971
|
-
|
|
972
|
-
{/* Create Group Modal */}
|
|
973
|
-
{isCreateOpen && (
|
|
974
|
-
<div style={{ position: "fixed", inset: 0, backgroundColor: "rgba(0,0,0,0.4)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 100 }}>
|
|
975
|
-
<form
|
|
976
|
-
ref={createGroupRef}
|
|
977
|
-
onSubmit={handleCreateGroup}
|
|
978
|
-
role="dialog"
|
|
979
|
-
aria-modal="true"
|
|
980
|
-
aria-labelledby="create-group-title"
|
|
981
|
-
{...(hasError(actionResult) ? { "aria-describedby": "create-group-error" } : {})}
|
|
982
|
-
data-testid="group-create-modal"
|
|
983
|
-
style={{ backgroundColor: "#ffffff", padding: "1.5rem", borderRadius: "8px", width: "360px", boxShadow: "0 10px 25px rgba(0,0,0,0.15)" }}>
|
|
984
|
-
<h3 id="create-group-title" style={{ margin: "0 0 1rem 0", fontSize: "1.1rem" }}>
|
|
985
|
-
{labels.groupsView.addGroup}
|
|
986
|
-
</h3>
|
|
987
|
-
<ActionError result={actionResult} id="create-group-error" />
|
|
988
|
-
|
|
989
|
-
{/* 上流から作る経路は候補一覧が在るときだけ提示する。出しておいて押せない
|
|
990
|
-
切替は、機能が壊れているのと見分けが付かない */}
|
|
991
|
-
{upstreamPickable && (
|
|
992
|
-
<fieldset data-testid="create-group-mode" style={{ display: "flex", gap: "0.25rem", marginBottom: "1rem", border: "none", padding: 0, margin: "0 0 1rem 0" }}>
|
|
993
|
-
{/* ⚠️ 本物のラジオ群である。同じ `name` を持つ入力にはブラウザが矢印キー移動と
|
|
994
|
-
「選択中の 1 つだけが Tab の停止点」を無償で与える。押しボタンに
|
|
995
|
-
`role="radio"` を被せると、その両方を手で書き直すことになる */}
|
|
996
|
-
<legend style={visuallyHidden}>{labels.groupsView.createModeLabel}</legend>
|
|
997
|
-
{(["local", "upstream"] as const).map((mode) => (
|
|
998
|
-
<label
|
|
999
|
-
key={mode}
|
|
1000
|
-
data-aqar-create-mode={mode}
|
|
1001
|
-
data-selected={createMode === mode ? "" : undefined}
|
|
1002
|
-
data-focused={focusedMode === mode ? "" : undefined}
|
|
1003
|
-
style={{
|
|
1004
|
-
outline: focusedMode === mode ? "2px solid #4338ca" : undefined,
|
|
1005
|
-
outlineOffset: "2px",
|
|
1006
|
-
flex: 1,
|
|
1007
|
-
padding: "0.4rem 0.6rem",
|
|
1008
|
-
fontSize: "0.8rem",
|
|
1009
|
-
borderRadius: "6px",
|
|
1010
|
-
cursor: "pointer",
|
|
1011
|
-
textAlign: "center",
|
|
1012
|
-
border: createMode === mode ? "1px solid #4338ca" : "1px solid #cbd5e1",
|
|
1013
|
-
backgroundColor: createMode === mode ? "#eef2ff" : "#ffffff",
|
|
1014
|
-
color: createMode === mode ? "#3730a3" : "#475569",
|
|
1015
|
-
fontWeight: createMode === mode ? 700 : 500,
|
|
1016
|
-
}}>
|
|
1017
|
-
<input
|
|
1018
|
-
type="radio"
|
|
1019
|
-
name="create-group-mode"
|
|
1020
|
-
value={mode}
|
|
1021
|
-
checked={createMode === mode}
|
|
1022
|
-
onChange={() => switchCreateMode(mode)}
|
|
1023
|
-
data-testid={`create-group-mode-${mode}`}
|
|
1024
|
-
data-aqar-visually-hidden
|
|
1025
|
-
onFocus={() => setFocusedMode(mode)}
|
|
1026
|
-
onBlur={() => setFocusedMode((current) => (current === mode ? null : current))}
|
|
1027
|
-
style={visuallyHidden}
|
|
1028
|
-
/>
|
|
1029
|
-
{mode === "local" ? labels.groupsView.createModeLocal : labels.groupsView.createModeUpstream}
|
|
1030
|
-
</label>
|
|
1031
|
-
))}
|
|
1032
|
-
</fieldset>
|
|
1033
|
-
)}
|
|
1034
|
-
|
|
1035
|
-
{data.catalog.kind === "ready" && data.catalog.groups.length === 0 && (
|
|
1036
|
-
<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" }}>
|
|
1037
|
-
{fill(labels.groupsView.catalogEmpty, { at: formatDateTime(data.catalog.observedAt ?? "", labels) })}
|
|
1038
|
-
</p>
|
|
1039
|
-
)}
|
|
1040
|
-
|
|
1041
|
-
{data.catalog.kind === "ready" && data.catalog.groups.length > 0 && data.directoryStatus.provider === "" && (
|
|
1042
|
-
<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" }}>
|
|
1043
|
-
{labels.groupsView.providerUnset}
|
|
1044
|
-
</p>
|
|
1045
|
-
)}
|
|
1046
|
-
|
|
1047
|
-
{data.catalog.kind === "unavailable" && (
|
|
1048
|
-
<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" }}>
|
|
1049
|
-
{/* ⚠️ この対話には上流アドレスの欄が無い (一覧が引けないと経路ごと消える)。
|
|
1050
|
-
「手入力してください」と案内すると、存在しない欄を探させることになる */}
|
|
1051
|
-
{fill(labels.groupsView.catalogUnavailableHere, { reason: labels.groupsView.catalogFailures[data.catalog.reason] })}
|
|
1052
|
-
</p>
|
|
1053
|
-
)}
|
|
1054
|
-
|
|
1055
|
-
{createMode === "upstream" && upstreamPickable && data.catalog.kind === "ready" && (
|
|
1056
|
-
<div style={{ marginBottom: "1rem" }}>
|
|
1057
|
-
<p data-testid="create-group-upstream-notice" role="note" style={{ margin: "0 0 0.5rem 0", fontSize: "0.78rem", color: "#5b21b6" }}>
|
|
1058
|
-
{labels.groupsView.createUpstreamNotice}
|
|
1059
|
-
</p>
|
|
1060
|
-
<div id="create-group-upstream-label" style={fieldLabel}>
|
|
1061
|
-
{labels.groupsView.pickUpstream}
|
|
1062
|
-
</div>
|
|
1063
|
-
{/* ⚠️ 選択欄は行を選んでいない Enter を「フォームへ渡す」設計であり、
|
|
1064
|
-
絞り込みを打った直後の Enter が **前回選んだ相手のまま** 送信になる。
|
|
1065
|
-
送信先は取り消せない委譲であるため、ここで暗黙送信だけを止める */}
|
|
1066
|
-
{/* biome-ignore lint/a11y/noStaticElementInteractions: 子の暗黙送信を止めるためだけの捕捉であり、対話要素ではない */}
|
|
1067
|
-
<div
|
|
1068
|
-
data-testid="create-group-upstream-picker"
|
|
1069
|
-
onKeyDown={(event) => {
|
|
1070
|
-
// ⚠️ 変換中の Enter には触れない。日本語 IME の確定は
|
|
1071
|
-
// `keydown{key:"Enter", keyCode:229, isComposing:true}` として届き、
|
|
1072
|
-
// ここで既定動作を止めると変換の確定そのものを潰し得る。
|
|
1073
|
-
// 変換中はそもそも暗黙の送信は起きないので、止める意味も無い
|
|
1074
|
-
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return
|
|
1075
|
-
if (event.key === "Enter") event.preventDefault()
|
|
1076
|
-
}}>
|
|
1077
|
-
<SelectBox
|
|
1078
|
-
options={options}
|
|
1079
|
-
value={upstream === null ? null : (options.find((o) => o.value === upstream.externalId) ?? null)}
|
|
1080
|
-
onChange={(picked: Option | Option[] | null) => chooseUpstream(picked === null || Array.isArray(picked) ? null : ((picked as { entry?: UserAdminCatalogGroup }).entry ?? null))}
|
|
1081
|
-
placeholder={labels.groupsView.pickUpstreamPlaceholder}
|
|
1082
|
-
ariaLabelledBy="create-group-upstream-label"
|
|
1083
|
-
renderOption={renderUpstreamOption}
|
|
1084
|
-
texts={pickerTexts(labels)}
|
|
1085
|
-
clearable
|
|
1086
|
-
/>
|
|
1087
|
-
</div>
|
|
1088
|
-
<CatalogFreshness observedAt={data.catalog.observedAt} isStale={data.catalog.isStale} labels={labels} />
|
|
1089
|
-
<div style={{ marginTop: "0.6rem" }}>
|
|
1090
|
-
<label htmlFor="create-group-membership-mode" style={fieldLabel}>
|
|
1091
|
-
{labels.directoryView.membershipMode}
|
|
1092
|
-
</label>
|
|
1093
|
-
<select
|
|
1094
|
-
id="create-group-membership-mode"
|
|
1095
|
-
data-testid="create-group-membership-mode"
|
|
1096
|
-
value={membershipMode}
|
|
1097
|
-
onChange={(e) => setMembershipMode(e.target.value as AuthDirectoryMembershipMode)}
|
|
1098
|
-
style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box" }}>
|
|
1099
|
-
<option value="direct">{labels.directoryView.directMode}</option>
|
|
1100
|
-
<option value="transitive">{labels.directoryView.transitiveMode}</option>
|
|
1101
|
-
</select>
|
|
1102
|
-
</div>
|
|
1103
|
-
</div>
|
|
1104
|
-
)}
|
|
1105
|
-
|
|
1106
|
-
<div style={{ marginBottom: "1rem" }}>
|
|
1107
|
-
<label htmlFor="create-group-key-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
|
|
1108
|
-
{labels.groupsView.groupKey}
|
|
1109
|
-
</label>
|
|
1110
|
-
<input
|
|
1111
|
-
id="create-group-key-input"
|
|
1112
|
-
type="text"
|
|
1113
|
-
value={newGroupKey}
|
|
1114
|
-
onChange={(e) => setNewGroupKey(e.target.value)}
|
|
1115
|
-
placeholder={labels.groupsView.groupKeyPlaceholder}
|
|
1116
|
-
required
|
|
1117
|
-
data-testid="create-group-key"
|
|
1118
|
-
style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box" }}
|
|
1119
|
-
/>
|
|
1120
|
-
</div>
|
|
1121
|
-
<div style={{ marginBottom: "1rem" }}>
|
|
1122
|
-
<label htmlFor="create-group-name-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
|
|
1123
|
-
{labels.groupsView.name}
|
|
1124
|
-
</label>
|
|
1125
|
-
<input
|
|
1126
|
-
id="create-group-name-input"
|
|
1127
|
-
type="text"
|
|
1128
|
-
value={newName}
|
|
1129
|
-
onChange={(e) => setNewName(e.target.value)}
|
|
1130
|
-
placeholder={labels.groupsView.namePlaceholder}
|
|
1131
|
-
required
|
|
1132
|
-
data-testid="create-group-name"
|
|
1133
|
-
style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box" }}
|
|
1134
|
-
/>
|
|
1135
|
-
</div>
|
|
1136
|
-
<div style={{ marginBottom: "1.2rem" }}>
|
|
1137
|
-
<label htmlFor="create-group-desc-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
|
|
1138
|
-
{labels.groupsView.description}
|
|
1139
|
-
</label>
|
|
1140
|
-
<textarea
|
|
1141
|
-
id="create-group-desc-input"
|
|
1142
|
-
value={newDescription}
|
|
1143
|
-
onChange={(e) => setNewDescription(e.target.value)}
|
|
1144
|
-
data-testid="create-group-description"
|
|
1145
|
-
style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box", height: "60px" }}
|
|
1146
|
-
/>
|
|
1147
|
-
</div>
|
|
1148
|
-
{/* ⚠️ 押せない理由を `title` だけに置かない。無効化されたボタンは焦点を持てず、
|
|
1149
|
-
読み上げの要素移動でも飛ばされ、Safari では tooltip も出ない — つまり
|
|
1150
|
-
キーボード・読み上げ・多くのマウス操作者のいずれにも届かない */}
|
|
1151
|
-
{createMode === "upstream" && upstream === null && (
|
|
1152
|
-
<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" }}>
|
|
1153
|
-
{/* ⚠️ 選べる候補が 1 件も無いときに「先に選べ」と言ってはならない。
|
|
1154
|
-
従いようのない指示であり、操作者は自分の見落としを探し続ける。
|
|
1155
|
-
候補は隠さない (どれが既に繋がっているのかを見せる価値は残る) */}
|
|
1156
|
-
{selectableUpstreamCount > 0 ? labels.groupsView.pickUpstreamFirst : labels.groupsView.allUpstreamLinked}
|
|
1157
|
-
</p>
|
|
1158
|
-
)}
|
|
1159
|
-
<div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
|
|
1160
|
-
<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" }}>
|
|
1161
|
-
{labels.common.cancel}
|
|
1162
|
-
</button>
|
|
1163
|
-
<button
|
|
1164
|
-
type="submit"
|
|
1165
|
-
data-testid="submit-create-group"
|
|
1166
|
-
// 上流モードで候補が空のまま押せると、送信は黙って捨てられる。押せない理由を
|
|
1167
|
-
// 見せる方が、無反応なボタンより短く伝わる
|
|
1168
|
-
disabled={busy || (createMode === "upstream" && upstream === null)}
|
|
1169
|
-
aria-busy={busy}
|
|
1170
|
-
{...(createMode === "upstream" && upstream === null ? { "aria-describedby": "create-group-submit-hint" } : {})}
|
|
1171
|
-
style={{
|
|
1172
|
-
padding: "6px 12px",
|
|
1173
|
-
borderRadius: "4px",
|
|
1174
|
-
backgroundColor: busy || (createMode === "upstream" && upstream === null) ? "#a5b4fc" : "#4338ca",
|
|
1175
|
-
color: "#ffffff",
|
|
1176
|
-
border: "none",
|
|
1177
|
-
cursor: busy || (createMode === "upstream" && upstream === null) ? "not-allowed" : "pointer",
|
|
1178
|
-
}}>
|
|
1179
|
-
{labels.common.create}
|
|
1180
|
-
</button>
|
|
1181
|
-
</div>
|
|
1182
|
-
</form>
|
|
1183
|
-
</div>
|
|
1184
|
-
)}
|
|
1185
|
-
|
|
1186
|
-
{/* Edit Group Modal */}
|
|
1187
|
-
{editingGroup && (
|
|
1188
|
-
<div style={{ position: "fixed", inset: 0, backgroundColor: "rgba(0,0,0,0.4)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 100 }}>
|
|
1189
|
-
<form
|
|
1190
|
-
ref={editGroupRef}
|
|
1191
|
-
onSubmit={handleUpdateGroup}
|
|
1192
|
-
role="dialog"
|
|
1193
|
-
aria-modal="true"
|
|
1194
|
-
aria-labelledby="edit-group-title"
|
|
1195
|
-
{...(hasError(actionResult) ? { "aria-describedby": "edit-group-error" } : {})}
|
|
1196
|
-
data-testid="group-edit-modal"
|
|
1197
|
-
style={{ backgroundColor: "#ffffff", padding: "1.5rem", borderRadius: "8px", width: "360px", boxShadow: "0 10px 25px rgba(0,0,0,0.15)" }}>
|
|
1198
|
-
<h3 id="edit-group-title" style={{ margin: "0 0 1rem 0", fontSize: "1.1rem" }}>
|
|
1199
|
-
{labels.groupsView.editGroup}
|
|
1200
|
-
</h3>
|
|
1201
|
-
<ActionError result={actionResult} id="edit-group-error" />
|
|
1202
|
-
<div style={{ marginBottom: "1rem" }}>
|
|
1203
|
-
<label htmlFor="edit-group-name-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
|
|
1204
|
-
{labels.groupsView.name}
|
|
1205
|
-
</label>
|
|
1206
|
-
<input
|
|
1207
|
-
id="edit-group-name-input"
|
|
1208
|
-
type="text"
|
|
1209
|
-
value={editingGroup.name}
|
|
1210
|
-
onChange={(e) => setModal({ kind: "edit", group: { ...editingGroup, name: e.target.value } })}
|
|
1211
|
-
required
|
|
1212
|
-
data-testid="edit-group-name"
|
|
1213
|
-
style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box" }}
|
|
1214
|
-
/>
|
|
1215
|
-
</div>
|
|
1216
|
-
<div style={{ marginBottom: "1.2rem" }}>
|
|
1217
|
-
<label htmlFor="edit-group-desc-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
|
|
1218
|
-
{labels.groupsView.description}
|
|
1219
|
-
</label>
|
|
1220
|
-
<textarea
|
|
1221
|
-
id="edit-group-desc-input"
|
|
1222
|
-
value={editingGroup.description ?? ""}
|
|
1223
|
-
onChange={(e) => setModal({ kind: "edit", group: { ...editingGroup, description: e.target.value } })}
|
|
1224
|
-
data-testid="edit-group-description"
|
|
1225
|
-
style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box", height: "60px" }}
|
|
1226
|
-
/>
|
|
1227
|
-
</div>
|
|
1228
|
-
<div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
|
|
1229
|
-
<button type="button" onClick={() => setModal(null)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
|
|
1230
|
-
{labels.common.cancel}
|
|
1231
|
-
</button>
|
|
1232
|
-
<button type="submit" data-testid="submit-edit-group" disabled={busy} aria-busy={busy} style={{ padding: "6px 12px", borderRadius: "4px", backgroundColor: "#4338ca", color: "#ffffff", border: "none", cursor: "pointer" }}>
|
|
1233
|
-
{labels.common.save}
|
|
1234
|
-
</button>
|
|
1235
|
-
</div>
|
|
1236
|
-
</form>
|
|
1237
|
-
</div>
|
|
1238
|
-
)}
|
|
1239
|
-
</section>
|
|
1240
|
-
)
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
/**
|
|
1244
|
-
* View for managing group member assignments.
|
|
1245
|
-
* グループメンバー割り当て管理ビュー。
|
|
1246
|
-
*/
|
|
1247
|
-
export function GroupMembersView() {
|
|
1248
|
-
const data = useLoaderData() as UserAdminLoaderData
|
|
1249
|
-
const fetcher = useFetcher()
|
|
1250
|
-
const { submit: submitThen, result: actionResult } = useSubmitThen(fetcher)
|
|
1251
|
-
const busy = fetcher.state !== "idle"
|
|
1252
|
-
const labels = data.labels ?? defaultUserAdminLabels
|
|
1253
|
-
|
|
1254
|
-
// 遷移元がグループを指定していればそれを選ぶ (グループ一覧から 1 クリックで来られるように)
|
|
1255
|
-
const [searchParams] = useSearchParams()
|
|
1256
|
-
const requested = Number(searchParams.get("group") ?? "")
|
|
1257
|
-
const deepLinked = Number.isInteger(requested) && requested > 0 ? requested : null
|
|
1258
|
-
const [picked, setPicked] = useState<number | null>(null)
|
|
1259
|
-
/**
|
|
1260
|
-
* 「所属のみ」表示中に、この画面で外した人。
|
|
1261
|
-
*
|
|
1262
|
-
* ⚠️ 外した行をその場で消すと、下の行が **カーソルの下へせり上がる**。上から順に外していく
|
|
1263
|
-
* のは所属を整理する自然な手順であり、往復のたびに次の「外す」ボタンが今いる位置へ来る。
|
|
1264
|
-
* 確認も無いため、普通の速さで押している運用者が意図しない人を外す
|
|
1265
|
-
*/
|
|
1266
|
-
const [justRemoved, setJustRemoved] = useState<ReadonlySet<number>>(new Set())
|
|
1267
|
-
/**
|
|
1268
|
-
* 深いリンクが指す先が変わったら、その指定に従う。
|
|
1269
|
-
*
|
|
1270
|
-
* ⚠️ 初期値としてだけ読むと、同じ面に居るまま `?group=` が変わっても (グループ面の各カードから
|
|
1271
|
-
* 続けて飛んだ場合) 選択が動かず、開いている面と URL が食い違う
|
|
1272
|
-
*/
|
|
1273
|
-
const lastDeepLink = useRef<number | null>(deepLinked)
|
|
1274
|
-
if (lastDeepLink.current !== deepLinked) {
|
|
1275
|
-
lastDeepLink.current = deepLinked
|
|
1276
|
-
if (deepLinked !== null) {
|
|
1277
|
-
setPicked(deepLinked)
|
|
1278
|
-
// 別のグループの「外したばかり」を持ち越さない (押しボタン経由と同じ扱い)
|
|
1279
|
-
setJustRemoved(new Set())
|
|
1280
|
-
}
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
const [memberQuery, setMemberQuery] = useState("")
|
|
1284
|
-
const [onlyMembers, setOnlyMembers] = useState(false)
|
|
1285
|
-
|
|
1286
|
-
// 再検証で対象が消えることがある。消えた ID を握り続けると空の面になる
|
|
1287
|
-
const requestedGroupId = picked ?? deepLinked ?? data.groups[0]?.id ?? null
|
|
1288
|
-
const currentGroup = data.groups.find((g) => g.id === requestedGroupId) ?? data.groups[0]
|
|
1289
|
-
/**
|
|
1290
|
-
* 頼んだ相手と、実際に出ている相手が違うときの、頼んだ側の ID。
|
|
1291
|
-
*
|
|
1292
|
-
* ⚠️ 判定は「**頼んだ相手が出ているか**」で行う。「深いリンクで来て、まだ一度も選んでいない」
|
|
1293
|
-
* ことを条件にすると、一度でも一覧から選んだ後は永久に黙る — そして代替が出る場面の大半は
|
|
1294
|
-
* そちらである。**別の運用者が今開いているグループを消した** ときがそれで、画面の見出しだけが
|
|
1295
|
-
* 入れ替わり、続けて押した「除外」は選んだ覚えのないグループへ書かれる。書込先を画面に
|
|
1296
|
-
* 合わせてある (下の `handleToggleMember`) ことが、この沈黙を余計に危うくする
|
|
1297
|
-
*/
|
|
1298
|
-
const missedTarget = requestedGroupId !== null && currentGroup !== undefined && currentGroup.id !== requestedGroupId ? requestedGroupId : null
|
|
1299
|
-
const currentMemberUserIds = new Set(data.groupMembers.filter((m) => m.groupId === currentGroup?.id).map((m) => m.userId))
|
|
1300
|
-
// 数百人の一覧を目で追わせない。名前と Email の両方を対象にする
|
|
1301
|
-
const needle = memberQuery.trim().toLowerCase()
|
|
1302
|
-
const visibleUsers = data.users.filter((u) => {
|
|
1303
|
-
// この画面で外した人は残す (行が消えると、下の行がカーソルの下へせり上がる)
|
|
1304
|
-
if (onlyMembers && !currentMemberUserIds.has(u.id) && !justRemoved.has(u.id)) return false
|
|
1305
|
-
if (needle === "") return true
|
|
1306
|
-
return u.displayName.toLowerCase().includes(needle) || u.email.toLowerCase().includes(needle)
|
|
1307
|
-
})
|
|
1308
|
-
|
|
1309
|
-
const handleToggleMember = (userId: number, isMember: boolean) => {
|
|
1310
|
-
// ⚠️ `aria-disabled` は焦点を残すために `disabled` の代わりに使っている。押せてしまう
|
|
1311
|
-
// ぶん、実際に断るのはここである
|
|
1312
|
-
if (busy) return
|
|
1313
|
-
// ⚠️ 書き込む先は **画面に出ているグループ** でなければならない。選択 ID をそのまま送ると、
|
|
1314
|
-
// 再検証で対象が消えて代替が表示されている間に、見えていない別グループを書き換える
|
|
1315
|
-
if (!currentGroup) return
|
|
1316
|
-
const groupId = String(currentGroup.id)
|
|
1317
|
-
if (isMember) {
|
|
1318
|
-
setJustRemoved((current) => new Set(current).add(userId))
|
|
1319
|
-
submitThen({ intent: "removeGroupMember", groupId, userId: String(userId) })
|
|
1320
|
-
} else {
|
|
1321
|
-
setJustRemoved((current) => {
|
|
1322
|
-
if (!current.has(userId)) return current
|
|
1323
|
-
const next = new Set(current)
|
|
1324
|
-
next.delete(userId)
|
|
1325
|
-
return next
|
|
1326
|
-
})
|
|
1327
|
-
submitThen({ intent: "addGroupMember", groupId, userId: String(userId) })
|
|
1328
|
-
}
|
|
1329
|
-
}
|
|
1330
|
-
|
|
1331
|
-
return (
|
|
1332
|
-
<section data-testid="group-members-view-section" style={{ display: "grid", gridTemplateColumns: "240px 1fr", gap: "1.2rem" }}>
|
|
1333
|
-
<h2 style={{ ...heading, gridColumn: "1 / -1" }}>{labels.groupMembersView.title}</h2>
|
|
1334
|
-
<ActionError result={actionResult} />
|
|
1335
|
-
{missedTarget !== null && (
|
|
1336
|
-
<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" }}>
|
|
1337
|
-
{fill(labels.groupMembersView.deepLinkMissed, { id: missedTarget, group: currentGroup?.name ?? "" })}
|
|
1338
|
-
</p>
|
|
1339
|
-
)}
|
|
1340
|
-
<div style={{ backgroundColor: "#ffffff", padding: "1rem", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
|
|
1341
|
-
<h3 style={{ margin: "0 0 0.8rem 0", fontSize: "0.9rem", color: "#475569" }}>{labels.groupMembersView.selectGroup}</h3>
|
|
1342
|
-
<div style={{ display: "flex", flexDirection: "column", gap: "0.4rem" }}>
|
|
1343
|
-
{data.groups.map((g) => {
|
|
1344
|
-
const isSelected = g.id === currentGroup?.id
|
|
1345
|
-
return (
|
|
1346
|
-
<button
|
|
1347
|
-
type="button"
|
|
1348
|
-
key={g.id}
|
|
1349
|
-
onClick={() => {
|
|
1350
|
-
setPicked(g.id)
|
|
1351
|
-
// 別のグループの「外したばかり」を持ち越さない
|
|
1352
|
-
setJustRemoved(new Set())
|
|
1353
|
-
}}
|
|
1354
|
-
data-testid={`select-group-btn-${g.id}`}
|
|
1355
|
-
style={{
|
|
1356
|
-
textAlign: "left",
|
|
1357
|
-
padding: "8px 10px",
|
|
1358
|
-
borderRadius: "6px",
|
|
1359
|
-
border: isSelected ? "1px solid #6366f1" : "1px solid #e2e8f0",
|
|
1360
|
-
backgroundColor: isSelected ? "#e0e7ff" : "#ffffff",
|
|
1361
|
-
color: isSelected ? "#3730a3" : "#0f172a",
|
|
1362
|
-
cursor: "pointer",
|
|
1363
|
-
fontWeight: isSelected ? 600 : 400,
|
|
1364
|
-
fontSize: "0.85rem",
|
|
1365
|
-
}}>
|
|
1366
|
-
{g.name}
|
|
1367
|
-
{g.readOnly ? (
|
|
1368
|
-
<span data-testid={`group-upstream-marker-${g.id}`} style={{ marginLeft: "6px", fontSize: "0.7rem", color: "#7c3aed" }}>
|
|
1369
|
-
{labels.common.managedUpstream}
|
|
1370
|
-
</span>
|
|
1371
|
-
) : null}
|
|
1372
|
-
</button>
|
|
1373
|
-
)
|
|
1374
|
-
})}
|
|
1375
|
-
</div>
|
|
1376
|
-
</div>
|
|
1377
|
-
|
|
1378
|
-
<div style={{ backgroundColor: "#ffffff", padding: "1.2rem", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
|
|
1379
|
-
{currentGroup ? (
|
|
1380
|
-
<>
|
|
1381
|
-
<div style={{ marginBottom: "1rem", borderBottom: "1px solid #e2e8f0", paddingBottom: "0.8rem" }}>
|
|
1382
|
-
<h3 style={{ margin: 0, fontSize: "1.1rem", color: "#0f172a" }}>{fill(labels.groupMembersView.membersOfTemplate, { group: currentGroup.name })}</h3>
|
|
1383
|
-
<span style={{ fontSize: "0.8rem", color: "#64748b" }}>
|
|
1384
|
-
{labels.groupMembersView.groupKeyLabel}: {currentGroup.groupKey}
|
|
1385
|
-
</span>
|
|
1386
|
-
{currentGroup.readOnly ? (
|
|
1387
|
-
<div data-testid="group-members-upstream-notice" role="note" style={{ marginTop: "0.5rem", padding: "0.5rem 0.7rem", borderRadius: "6px", backgroundColor: "#f5f3ff", border: "1px solid #ddd6fe", color: "#5b21b6", fontSize: "0.8rem" }}>
|
|
1388
|
-
{labels.groupMembersView.upstreamNotice}
|
|
1389
|
-
</div>
|
|
1390
|
-
) : null}
|
|
1391
|
-
</div>
|
|
1392
|
-
<div style={{ display: "flex", gap: "0.75rem", alignItems: "center", flexWrap: "wrap", marginBottom: "0.8rem" }}>
|
|
1393
|
-
<input
|
|
1394
|
-
type="search"
|
|
1395
|
-
aria-label={labels.groupMembersView.searchLabel}
|
|
1396
|
-
placeholder={labels.groupMembersView.searchPlaceholder}
|
|
1397
|
-
value={memberQuery}
|
|
1398
|
-
onChange={(e) => setMemberQuery(e.target.value)}
|
|
1399
|
-
data-testid="member-search-input"
|
|
1400
|
-
style={{ flex: 1, minWidth: "220px", padding: "6px 12px", borderRadius: "6px", border: "1px solid #cbd5e1", fontSize: "0.85rem" }}
|
|
1401
|
-
/>
|
|
1402
|
-
<label style={{ display: "inline-flex", alignItems: "center", gap: "0.35rem", fontSize: "0.8rem", color: "#475569" }}>
|
|
1403
|
-
<input
|
|
1404
|
-
type="checkbox"
|
|
1405
|
-
data-testid="member-only-toggle"
|
|
1406
|
-
checked={onlyMembers}
|
|
1407
|
-
onChange={(e) => {
|
|
1408
|
-
setOnlyMembers(e.target.checked)
|
|
1409
|
-
// 絞り込みを掛け直したら、その時点の所属で数え直す。
|
|
1410
|
-
// 残すと「外したはずの人が所属のみ表示に居る」状態が続く
|
|
1411
|
-
setJustRemoved(new Set())
|
|
1412
|
-
}}
|
|
1413
|
-
/>
|
|
1414
|
-
{fill(labels.groupMembersView.onlyMembers, { count: currentMemberUserIds.size })}
|
|
1415
|
-
</label>
|
|
1416
|
-
</div>
|
|
1417
|
-
{/* 絞り込みの結果は視覚的にしか変わらない。読み上げへ届けるには live region が要る */}
|
|
1418
|
-
<p data-testid="member-summary" role="status" aria-live="polite" style={{ margin: "0 0 0.6rem 0", fontSize: "0.78rem", color: "#475569" }}>
|
|
1419
|
-
{fill(labels.groupMembersView.summary, { shown: visibleUsers.length, total: data.users.length, members: currentMemberUserIds.size })}
|
|
1420
|
-
</p>
|
|
1421
|
-
{visibleUsers.length === 0 && (
|
|
1422
|
-
<p data-testid="member-empty-filtered" style={{ margin: "0 0 0.6rem 0", fontSize: "0.85rem", color: "#475569" }}>
|
|
1423
|
-
{/* 効いている絞り込みを名指しする。打っていない検索のせいにしない。
|
|
1424
|
-
そもそも利用者が 1 人も居ない配備を「検索で 0 件」と言わない */}
|
|
1425
|
-
{data.users.length === 0 ? labels.groupMembersView.noUsers : needle === "" && onlyMembers ? labels.groupMembersView.emptyNoMembers : labels.groupMembersView.emptyFiltered}
|
|
1426
|
-
</p>
|
|
1427
|
-
)}
|
|
1428
|
-
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
|
1429
|
-
{visibleUsers.map((u) => {
|
|
1430
|
-
const isMember = currentMemberUserIds.has(u.id)
|
|
1431
|
-
return (
|
|
1432
|
-
<div
|
|
1433
|
-
key={u.id}
|
|
1434
|
-
style={{
|
|
1435
|
-
display: "flex",
|
|
1436
|
-
justifyContent: "space-between",
|
|
1437
|
-
alignItems: "center",
|
|
1438
|
-
padding: "8px 12px",
|
|
1439
|
-
borderRadius: "6px",
|
|
1440
|
-
border: "1px solid #f1f5f9",
|
|
1441
|
-
backgroundColor: isMember ? "#f0fdf4" : "#ffffff",
|
|
1442
|
-
}}
|
|
1443
|
-
data-testid={`member-row-${u.id}`}>
|
|
1444
|
-
<div>
|
|
1445
|
-
<span style={{ fontWeight: 600, fontSize: "0.88rem", color: "#0f172a", marginRight: "8px" }}>{u.displayName}</span>
|
|
1446
|
-
<span style={{ fontSize: "0.78rem", color: "#64748b" }}>({u.email})</span>
|
|
1447
|
-
</div>
|
|
1448
|
-
{currentGroup.readOnly ? (
|
|
1449
|
-
isMember ? (
|
|
1450
|
-
<span data-testid={`member-readonly-${u.id}`} style={{ fontSize: "0.75rem", color: "#475569" }}>
|
|
1451
|
-
{labels.common.managedUpstream}
|
|
1452
|
-
</span>
|
|
1453
|
-
) : null
|
|
1454
|
-
) : (
|
|
1455
|
-
<button
|
|
1456
|
-
type="button"
|
|
1457
|
-
onClick={() => handleToggleMember(u.id, isMember)}
|
|
1458
|
-
{...rowActionProps(busy)}
|
|
1459
|
-
aria-label={fill(isMember ? labels.groupMembersView.actionRemoveAria : labels.groupMembersView.actionAddAria, { user: u.displayName })}
|
|
1460
|
-
data-testid={`toggle-member-btn-${u.id}`}
|
|
1461
|
-
style={{
|
|
1462
|
-
padding: "4px 12px",
|
|
1463
|
-
borderRadius: "4px",
|
|
1464
|
-
border: isMember ? "1px solid #b91c1c" : "1px solid #0284c7",
|
|
1465
|
-
backgroundColor: isMember ? "#ffffff" : "#0284c7",
|
|
1466
|
-
color: isMember ? "#b91c1c" : "#ffffff",
|
|
1467
|
-
fontSize: "0.78rem",
|
|
1468
|
-
fontWeight: 600,
|
|
1469
|
-
cursor: "pointer",
|
|
1470
|
-
}}>
|
|
1471
|
-
{isMember ? labels.groupMembersView.actionRemove : labels.groupMembersView.actionAdd}
|
|
1472
|
-
</button>
|
|
1473
|
-
)}
|
|
1474
|
-
</div>
|
|
1475
|
-
)
|
|
1476
|
-
})}
|
|
1477
|
-
</div>
|
|
1478
|
-
</>
|
|
1479
|
-
) : (
|
|
1480
|
-
<div data-testid="member-no-groups" style={{ color: "#475569", textAlign: "center", padding: "2rem" }}>
|
|
1481
|
-
{/* 左の一覧が空なら「選べ」は従えない指示である。何が無いのかを言う */}
|
|
1482
|
-
{data.groups.length === 0 ? labels.groupMembersView.noGroups : labels.groupMembersView.selectPrompt}
|
|
1483
|
-
</div>
|
|
1484
|
-
)}
|
|
1485
|
-
</div>
|
|
1486
|
-
</section>
|
|
1487
|
-
)
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
/** Badge colours per allowlist kind. 許可種別ごとのバッジ配色。 */
|
|
1491
|
-
function allowlistBadgeStyle(type: AuthAllowlistEntry["type"]): { backgroundColor: string; color: string } {
|
|
1492
|
-
if (type === "email") return { backgroundColor: "#e0f2fe", color: "#0369a1" }
|
|
1493
|
-
if (type === "domain") return { backgroundColor: "#fef3c7", color: "#b45309" }
|
|
1494
|
-
return { backgroundColor: "#ede9fe", color: "#6d28d9" }
|
|
1495
|
-
}
|
|
1496
|
-
|
|
1497
|
-
/** Badge text per allowlist kind. 許可種別ごとのバッジ文言。 */
|
|
1498
|
-
function allowlistTypeLabel(type: AuthAllowlistEntry["type"], labels: UserAdminLabels): string {
|
|
1499
|
-
if (type === "email") return labels.allowlistView.emailTypeShort
|
|
1500
|
-
if (type === "domain") return labels.allowlistView.domainTypeShort
|
|
1501
|
-
return labels.allowlistView.groupTypeShort
|
|
1502
|
-
}
|
|
1503
|
-
|
|
1504
|
-
/**
|
|
1505
|
-
* View for managing authentication allowlist entries.
|
|
1506
|
-
* アクセス許可リスト (Allowlist) 管理ビュー。
|
|
1507
|
-
*/
|
|
1508
|
-
export function AllowlistView() {
|
|
1509
|
-
const data = useLoaderData() as UserAdminLoaderData
|
|
1510
|
-
const fetcher = useFetcher()
|
|
1511
|
-
const labels = data.labels ?? defaultUserAdminLabels
|
|
1512
|
-
|
|
1513
|
-
const { submit: submitThen, result: actionResult } = useSubmitThen(fetcher)
|
|
1514
|
-
const busy = fetcher.state !== "idle"
|
|
1515
|
-
const [pattern, setPattern] = useState("")
|
|
1516
|
-
const [type, setType] = useState<"email" | "domain">("email")
|
|
1517
|
-
const [description, setDescription] = useState("")
|
|
1518
|
-
|
|
1519
|
-
const handleAddEntry = (e: React.FormEvent) => {
|
|
1520
|
-
e.preventDefault()
|
|
1521
|
-
if (!pattern) return
|
|
1522
|
-
const sent = { pattern, description }
|
|
1523
|
-
submitThen({ intent: "addAllowlistEntry", pattern: sent.pattern, type, description: sent.description }, () => {
|
|
1524
|
-
setPattern((current) => (current === sent.pattern ? "" : current))
|
|
1525
|
-
setDescription((current) => (current === sent.description ? "" : current))
|
|
1526
|
-
})
|
|
1527
|
-
}
|
|
1528
|
-
|
|
1529
|
-
const handleRemoveEntry = (id: number) => {
|
|
1530
|
-
submitThen({ intent: "removeAllowlistEntry", entryId: String(id) })
|
|
1531
|
-
}
|
|
1532
|
-
|
|
1533
|
-
/** グループ許可は別の表に住むため、別の intent で消す。 */
|
|
1534
|
-
const handleRemoveGroupEntry = (id: number) => {
|
|
1535
|
-
submitThen({ intent: "removeAllowlistGroup", entryId: String(id) })
|
|
1536
|
-
}
|
|
1537
|
-
|
|
1538
|
-
return (
|
|
1539
|
-
<section data-testid="allowlist-view-section" style={{ display: "flex", flexDirection: "column", gap: "1.2rem" }}>
|
|
1540
|
-
<h2 style={heading}>{labels.allowlistView.title}</h2>
|
|
1541
|
-
<ActionError result={actionResult} />
|
|
1542
|
-
<form onSubmit={handleAddEntry} style={{ backgroundColor: "#ffffff", padding: "1.2rem", borderRadius: "8px", border: "1px solid #e2e8f0", display: "flex", flexWrap: "wrap", gap: "0.8rem", alignItems: "flex-end" }}>
|
|
1543
|
-
<div style={{ flex: 2, minWidth: "220px" }}>
|
|
1544
|
-
<label htmlFor="allowlist-pattern-input-field" style={{ display: "block", fontSize: "0.78rem", color: "#475569", marginBottom: "4px" }}>
|
|
1545
|
-
{labels.allowlistView.pattern}
|
|
1546
|
-
</label>
|
|
1547
|
-
<input
|
|
1548
|
-
id="allowlist-pattern-input-field"
|
|
1549
|
-
type="text"
|
|
1550
|
-
value={pattern}
|
|
1551
|
-
onChange={(e) => setPattern(e.target.value)}
|
|
1552
|
-
required
|
|
1553
|
-
data-testid="allowlist-pattern-input"
|
|
1554
|
-
style={{ width: "100%", padding: "6px 10px", borderRadius: "6px", border: "1px solid #cbd5e1", fontSize: "0.85rem", boxSizing: "border-box" }}
|
|
1555
|
-
/>
|
|
1556
|
-
</div>
|
|
1557
|
-
<div style={{ flex: 1, minWidth: "140px" }}>
|
|
1558
|
-
<label htmlFor="allowlist-type-select-field" style={{ display: "block", fontSize: "0.78rem", color: "#475569", marginBottom: "4px" }}>
|
|
1559
|
-
{labels.allowlistView.type}
|
|
1560
|
-
</label>
|
|
1561
|
-
<select
|
|
1562
|
-
id="allowlist-type-select-field"
|
|
1563
|
-
value={type}
|
|
1564
|
-
onChange={(e) => setType(e.target.value as "email" | "domain")}
|
|
1565
|
-
data-testid="allowlist-type-select"
|
|
1566
|
-
style={{ width: "100%", padding: "6px 10px", borderRadius: "6px", border: "1px solid #cbd5e1", fontSize: "0.85rem", boxSizing: "border-box" }}>
|
|
1567
|
-
<option value="email">{labels.allowlistView.emailType}</option>
|
|
1568
|
-
<option value="domain">{labels.allowlistView.domainType}</option>
|
|
1569
|
-
</select>
|
|
1570
|
-
</div>
|
|
1571
|
-
<div style={{ flex: 2, minWidth: "200px" }}>
|
|
1572
|
-
<label htmlFor="allowlist-desc-input-field" style={{ display: "block", fontSize: "0.78rem", color: "#475569", marginBottom: "4px" }}>
|
|
1573
|
-
{labels.allowlistView.description}
|
|
1574
|
-
</label>
|
|
1575
|
-
<input
|
|
1576
|
-
id="allowlist-desc-input-field"
|
|
1577
|
-
type="text"
|
|
1578
|
-
value={description}
|
|
1579
|
-
onChange={(e) => setDescription(e.target.value)}
|
|
1580
|
-
data-testid="allowlist-desc-input"
|
|
1581
|
-
style={{ width: "100%", padding: "6px 10px", borderRadius: "6px", border: "1px solid #cbd5e1", fontSize: "0.85rem", boxSizing: "border-box" }}
|
|
1582
|
-
/>
|
|
1583
|
-
</div>
|
|
1584
|
-
<button type="submit" data-testid="submit-allowlist-add" disabled={busy} aria-busy={busy} style={{ padding: "7px 16px", borderRadius: "6px", backgroundColor: "#047857", color: "#ffffff", border: "none", fontSize: "0.85rem", fontWeight: 600, cursor: "pointer" }}>
|
|
1585
|
-
{labels.allowlistView.addEntry}
|
|
1586
|
-
</button>
|
|
1587
|
-
</form>
|
|
1588
|
-
|
|
1589
|
-
<div style={{ backgroundColor: "#ffffff", borderRadius: "8px", border: "1px solid #e2e8f0", overflow: "hidden" }}>
|
|
1590
|
-
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.85rem", textAlign: "left" }}>
|
|
1591
|
-
<thead>
|
|
1592
|
-
<tr style={{ backgroundColor: "#f8fafc", borderBottom: "1px solid #e2e8f0", color: "#475569" }}>
|
|
1593
|
-
<th scope="col" style={{ padding: "10px 14px" }}>
|
|
1594
|
-
{labels.common.id}
|
|
1595
|
-
</th>
|
|
1596
|
-
<th scope="col" style={{ padding: "10px 14px" }}>
|
|
1597
|
-
{labels.allowlistView.target}
|
|
1598
|
-
</th>
|
|
1599
|
-
<th scope="col" style={{ padding: "10px 14px" }}>
|
|
1600
|
-
{labels.allowlistView.type}
|
|
1601
|
-
</th>
|
|
1602
|
-
<th scope="col" style={{ padding: "10px 14px" }}>
|
|
1603
|
-
{labels.allowlistView.description}
|
|
1604
|
-
</th>
|
|
1605
|
-
<th scope="col" style={{ padding: "10px 14px", textAlign: "right" }}>
|
|
1606
|
-
{labels.common.actions}
|
|
1607
|
-
</th>
|
|
1608
|
-
</tr>
|
|
1609
|
-
</thead>
|
|
1610
|
-
<tbody>
|
|
1611
|
-
{data.allowlist.map((item) => (
|
|
1612
|
-
<tr key={`${item.type}-${item.id}`} style={{ borderBottom: "1px solid #f1f5f9" }} data-testid={`allowlist-row-${item.type}-${item.id}`} data-allowlist-type={item.type}>
|
|
1613
|
-
<td style={{ padding: "10px 14px", color: "#475569" }}>{fill(labels.common.idTemplate, { id: item.id })}</td>
|
|
1614
|
-
<td style={{ padding: "10px 14px", fontWeight: 600, color: "#0f172a" }}>
|
|
1615
|
-
{/* パターン許可とグループ許可では正準値が違う。グループはアドレスではなく
|
|
1616
|
-
グループそのものが対象なので、名前とキーを見せる */}
|
|
1617
|
-
{item.type === "group" ? (
|
|
1618
|
-
<span data-testid={`allowlist-group-${item.type}-${item.id}`}>
|
|
1619
|
-
{item.groupName}
|
|
1620
|
-
<span style={{ marginLeft: "6px", fontFamily: "monospace", fontSize: "0.78rem", color: "#64748b" }}>{item.groupKey}</span>
|
|
1621
|
-
</span>
|
|
1622
|
-
) : (
|
|
1623
|
-
<span style={{ fontFamily: "monospace" }}>{item.pattern}</span>
|
|
1624
|
-
)}
|
|
1625
|
-
</td>
|
|
1626
|
-
<td style={{ padding: "10px 14px" }}>
|
|
1627
|
-
<span style={{ fontSize: "0.75rem", padding: "2px 8px", borderRadius: "10px", ...allowlistBadgeStyle(item.type), fontWeight: 600 }}>{allowlistTypeLabel(item.type, labels)}</span>
|
|
1628
|
-
</td>
|
|
1629
|
-
<td style={{ padding: "10px 14px", color: "#475569" }}>
|
|
1630
|
-
{item.description || labels.common.none}
|
|
1631
|
-
{/* 配備全体が所有する行は、どのテナントの管理者でも編集できない */}
|
|
1632
|
-
{item.owner.kind === "deployment" && (
|
|
1633
|
-
<span data-testid={`allowlist-owner-${item.type}-${item.id}`} style={{ marginLeft: "8px", fontSize: "0.72rem", color: "#475569" }}>
|
|
1634
|
-
{labels.allowlistView.deploymentOwner}
|
|
1635
|
-
</span>
|
|
1636
|
-
)}
|
|
1637
|
-
</td>
|
|
1638
|
-
<td style={{ padding: "10px 14px", textAlign: "right" }}>
|
|
1639
|
-
{/* 変更不能な供給元 (環境変数など) は削除ボタンを出さない。無効化ではなく
|
|
1640
|
-
非描画にするのは、押せないボタンが押せる操作と見分けられないため */}
|
|
1641
|
-
{item.readOnly ? (
|
|
1642
|
-
<span data-testid={`readonly-allowlist-${item.type}-${item.id}`} style={{ fontSize: "0.75rem", color: "#475569" }}>
|
|
1643
|
-
{labels.common.readOnly}
|
|
1644
|
-
</span>
|
|
1645
|
-
) : (
|
|
1646
|
-
<button
|
|
1647
|
-
type="button"
|
|
1648
|
-
onClick={() => {
|
|
1649
|
-
// これでしかサインインできない人はログインできなくなる。取り消せない
|
|
1650
|
-
if (!window.confirm(fill(labels.allowlistView.confirmDelete, { target: item.type === "group" ? item.groupName : item.pattern }))) return
|
|
1651
|
-
if (item.type === "group") handleRemoveGroupEntry(item.id)
|
|
1652
|
-
else handleRemoveEntry(item.id)
|
|
1653
|
-
}}
|
|
1654
|
-
disabled={busy}
|
|
1655
|
-
aria-busy={busy}
|
|
1656
|
-
aria-label={fill(labels.allowlistView.deleteAria, { target: item.type === "group" ? item.groupName : item.pattern })}
|
|
1657
|
-
data-testid={`remove-allowlist-btn-${item.type}-${item.id}`}
|
|
1658
|
-
style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #fee2e2", backgroundColor: "#fff5f5", color: "#b91c1c", fontSize: "0.78rem", cursor: "pointer" }}>
|
|
1659
|
-
{labels.common.delete}
|
|
1660
|
-
</button>
|
|
1661
|
-
)}
|
|
1662
|
-
</td>
|
|
1663
|
-
</tr>
|
|
1664
|
-
))}
|
|
1665
|
-
{data.allowlist.length === 0 && (
|
|
1666
|
-
<tr>
|
|
1667
|
-
<td colSpan={5} style={{ padding: "2rem", textAlign: "center", color: "#475569" }}>
|
|
1668
|
-
{labels.allowlistView.empty}
|
|
1669
|
-
</td>
|
|
1670
|
-
</tr>
|
|
1671
|
-
)}
|
|
1672
|
-
</tbody>
|
|
1673
|
-
</table>
|
|
1674
|
-
</div>
|
|
1675
|
-
</section>
|
|
1676
|
-
)
|
|
1677
|
-
}
|
|
1678
|
-
|
|
1679
|
-
/**
|
|
1680
|
-
* View for the upstream directory: what is linked, how fresh it is, and the controls that change it.
|
|
1681
|
-
* 上流ディレクトリのビュー。何が連携され、どれだけ新しく、それを変える操作を提供する。
|
|
1682
|
-
*
|
|
1683
|
-
* このタブが無いと、連携の作成・解除・一時停止・即時同期・グループ単位のサインイン許可という
|
|
1684
|
-
* 5 つの操作にどこからも到達できない。実装されているのに押せる場所が無い状態は、実装が無いのと
|
|
1685
|
-
* 変わらないうえ、「許可を消せるが作れない」という片肺の運用を強いる。
|
|
1686
|
-
*/
|
|
1687
|
-
export function DirectoryView() {
|
|
1688
|
-
const data = useLoaderData() as UserAdminLoaderData
|
|
1689
|
-
const fetcher = useFetcher()
|
|
1690
|
-
const { submit: submitThen, result: actionResult } = useSubmitThen(fetcher)
|
|
1691
|
-
const labels = data.labels ?? defaultUserAdminLabels
|
|
1692
|
-
const L = labels.directoryView
|
|
1693
|
-
|
|
1694
|
-
const linkable = data.groups.filter((g) => !g.externalLink)
|
|
1695
|
-
const [groupId, setGroupId] = useState("")
|
|
1696
|
-
const [externalKey, setExternalKey] = useState("")
|
|
1697
|
-
// ⚠️ 選んだ候補の **安定 ID** を別に持つ。アドレスだけで引き当てると、同じアドレスの行が
|
|
1698
|
-
// 2 つある配備 (別名行、改名の途中) で、選んだのとは違う行が控えに出る。手入力に切り替えた
|
|
1699
|
-
// ときは空に戻す — 手入力は識別子を証明できないので、サーバー側もアドレスで解決する
|
|
1700
|
-
const [pickedExternalId, setPickedExternalId] = useState("")
|
|
1701
|
-
const [membershipMode, setMembershipMode] = useState<AuthDirectoryMembershipMode>("direct")
|
|
1702
|
-
|
|
1703
|
-
const linked = data.groups.filter((g) => g.externalLink)
|
|
1704
|
-
const allowedGroupIds = new Set(data.allowlist.filter((e) => e.type === "group").map((e) => e.groupId))
|
|
1705
|
-
const allowlistEntryOf = (id: number) => data.allowlist.find((e) => e.type === "group" && e.groupId === id)
|
|
1706
|
-
const busy = fetcher.state !== "idle"
|
|
1707
|
-
// 「要求しました」を出す対象。押しても何も変わらないと、何度も押される
|
|
1708
|
-
const [queued, setQueued] = useState<{ groupId: number; syncedAtAtPress: string | null } | null>(null)
|
|
1709
|
-
/**
|
|
1710
|
-
* 直前の「今すぐ同期」が積まれなかったことの通知。
|
|
1711
|
-
*
|
|
1712
|
-
* ⚠️ 積まれなかったことを成功と同じ見た目にしてはならない。同じ範囲の要求が既に待っている
|
|
1713
|
-
* とき積み増さないのは正しいが、黙って成功に見せると、押しても何も起きない画面になる —
|
|
1714
|
-
* しかもその待っている行が誰にも拾われなければ、以後の押下すべてが同じように消える
|
|
1715
|
-
*/
|
|
1716
|
-
const syncRequestNotice = syncRequestNoticeOf(actionResult, labels)
|
|
1717
|
-
/**
|
|
1718
|
-
* 「要求しました」を出し続けてよいグループ。
|
|
1719
|
-
*
|
|
1720
|
-
* ⚠️ **押した時刻より後に同期が成功していたら、もう出さない。** 出し続けると、運用者は
|
|
1721
|
-
* 新しく押した要求と古い表示を見分けられず、同期が終わったのかどうかも判らない。
|
|
1722
|
-
* 同期の完了は `syncedAt` が進んだことで判る — それが唯一の観測点である
|
|
1723
|
-
*/
|
|
1724
|
-
const queuedGroupId = (() => {
|
|
1725
|
-
if (queued === null) return null
|
|
1726
|
-
const target = data.groups.find((g) => g.id === queued.groupId)
|
|
1727
|
-
const syncedAt = target?.externalLink?.syncedAt ?? null
|
|
1728
|
-
// ⚠️ 端末の時計を判定に使わない。押した時刻 (ブラウザ) と同期時刻 (サーバー) を比べると、
|
|
1729
|
-
// 進んだ時計では控えが永久に消えず、遅れた時計では押した直後に消える。**押した瞬間に
|
|
1730
|
-
// 見えていた同期時刻から変わったか** だけを見る — どちらもサーバーが作った値である
|
|
1731
|
-
if (syncedAt !== queued.syncedAtAtPress) return null
|
|
1732
|
-
return queued.groupId
|
|
1733
|
-
})()
|
|
1734
|
-
// 供給元が判らないまま欄を出すと、送信は必ずサーバー側の内部文言で失敗する
|
|
1735
|
-
const canLink = data.directoryStatus.isEnabled && data.directoryStatus.provider !== ""
|
|
1736
|
-
const catalogOptions = useMemo(() => upstreamOptions(data.catalog, upstreamIndex(linked), labels), [data.catalog, linked, labels])
|
|
1737
|
-
// ⚠️ 候補は隠さない (どのローカルグループが押さえているかを見せる価値は残る) が、選べる候補が
|
|
1738
|
-
// 1 件も無いなら **その理由を言う**。言わないと、すべて選べない選択欄と押せないボタンだけが
|
|
1739
|
-
// 残り、操作者は自分の見落としを探し続ける
|
|
1740
|
-
const selectableCatalogCount = useMemo(() => catalogOptions.filter((o) => o.disabled !== true).length, [catalogOptions])
|
|
1741
|
-
|
|
1742
|
-
/** 連携を作る。上流キーは運用者が知っている値 (グループアドレス) で受け取る。 */
|
|
1743
|
-
const handleLink = (e: React.FormEvent) => {
|
|
1744
|
-
e.preventDefault()
|
|
1745
|
-
if (!(groupId && externalKey)) return
|
|
1746
|
-
// 連携すると名前・説明・メンバー・削除のすべてが上流の管理下へ移る。作成側と同じく、
|
|
1747
|
-
// 戻せない操作であることを先に伝える
|
|
1748
|
-
const target = linkable.find((g) => String(g.id) === groupId)
|
|
1749
|
-
if (!window.confirm(fill(L.confirmLink, { group: target?.name ?? groupId, upstream: externalKey }))) return
|
|
1750
|
-
// 供給元は配備の設定から来る。ここに特定の値を書くと、別の上流を使う配備で作った
|
|
1751
|
-
// リンクが同期側で恒久的に拒否される
|
|
1752
|
-
const sentKey = externalKey
|
|
1753
|
-
submitThen({ intent: "linkDirectoryGroup", groupId, provider: data.directoryStatus.provider, externalKey: sentKey, externalId: pickedExternalId, membershipMode }, () => {
|
|
1754
|
-
setExternalKey((current) => (current === sentKey ? "" : current))
|
|
1755
|
-
setPickedExternalId((current) => (externalKey === sentKey ? "" : current))
|
|
1756
|
-
// 連携済みになった行は候補から消える。選択を残すと、表示は空欄なのに前の
|
|
1757
|
-
// グループへ送信される
|
|
1758
|
-
setGroupId("")
|
|
1759
|
-
})
|
|
1760
|
-
}
|
|
1761
|
-
|
|
1762
|
-
return (
|
|
1763
|
-
<section data-testid="directory-view-section" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
|
1764
|
-
<h2 style={heading}>{L.title}</h2>
|
|
1765
|
-
<ActionError result={actionResult} />
|
|
1766
|
-
|
|
1767
|
-
{!canLink && (
|
|
1768
|
-
<p data-testid="directory-not-wired" style={{ margin: 0, padding: "0.7rem 1rem", borderRadius: "6px", border: "1px solid #e2e8f0", backgroundColor: "#f8fafc", color: "#475569", fontSize: "0.85rem" }}>
|
|
1769
|
-
{L.notWiredNotice}
|
|
1770
|
-
</p>
|
|
1771
|
-
)}
|
|
1772
|
-
|
|
1773
|
-
{/* ⚠️ 連携候補が無い理由は 2 つあり、向かわせる先が逆である。グループが 1 つも無いのに
|
|
1774
|
-
「すべて連携済み」と言うと、運用者は存在しない連携済み一覧を探し、真の次の一手
|
|
1775
|
-
(グループタブで作る) に辿り着けない。空集合の意味を先に分ける */}
|
|
1776
|
-
{canLink && data.groups.length === 0 && (
|
|
1777
|
-
<p data-testid="directory-no-groups" style={{ margin: 0, padding: "0.7rem 1rem", borderRadius: "6px", border: "1px solid #e2e8f0", backgroundColor: "#f8fafc", color: "#475569", fontSize: "0.85rem" }}>
|
|
1778
|
-
{L.noGroupsToLink}
|
|
1779
|
-
</p>
|
|
1780
|
-
)}
|
|
1781
|
-
|
|
1782
|
-
{canLink && data.groups.length > 0 && linkable.length === 0 && (
|
|
1783
|
-
<p data-testid="directory-nothing-to-link" style={{ margin: 0, padding: "0.7rem 1rem", borderRadius: "6px", border: "1px solid #e2e8f0", backgroundColor: "#f8fafc", color: "#475569", fontSize: "0.85rem" }}>
|
|
1784
|
-
{L.nothingToLink}
|
|
1785
|
-
</p>
|
|
1786
|
-
)}
|
|
1787
|
-
|
|
1788
|
-
{/* ⚠️ ライブリージョンは **常に置いておき、中身だけ入れ替える**。内容と一緒に挿入した
|
|
1789
|
-
`role="status"` は読み上げられない — 押しても何も起きなかったことを伝える唯一の
|
|
1790
|
-
手段が、その伝えたい場面でだけ無言になる */}
|
|
1791
|
-
<p
|
|
1792
|
-
data-testid="directory-sync-already-pending"
|
|
1793
|
-
role="status"
|
|
1794
|
-
aria-live="polite"
|
|
1795
|
-
hidden={syncRequestNotice === null}
|
|
1796
|
-
style={syncRequestNotice === null ? undefined : { margin: 0, padding: "0.5rem 0.7rem", borderRadius: "6px", backgroundColor: "#fffbeb", border: "1px solid #fde68a", color: "#92400e", fontSize: "0.8rem" }}>
|
|
1797
|
-
{syncRequestNotice}
|
|
1798
|
-
</p>
|
|
1799
|
-
|
|
1800
|
-
{canLink && linkable.length > 0 && (
|
|
1801
|
-
<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" }}>
|
|
1802
|
-
<div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
|
1803
|
-
<label htmlFor="directory-group-select-input" style={fieldLabel}>
|
|
1804
|
-
{L.selectGroup}
|
|
1805
|
-
</label>
|
|
1806
|
-
<select id="directory-group-select-input" data-testid="directory-group-select" value={groupId} onChange={(e) => setGroupId(e.target.value)} style={{ padding: "0.5rem", borderRadius: "6px", border: "1px solid #cbd5e1" }}>
|
|
1807
|
-
<option value="">{L.selectGroup}</option>
|
|
1808
|
-
{linkable.map((g) => (
|
|
1809
|
-
<option key={g.id} value={String(g.id)}>
|
|
1810
|
-
{fill(L.groupOptionTemplate, { name: g.name, key: g.groupKey })}
|
|
1811
|
-
</option>
|
|
1812
|
-
))}
|
|
1813
|
-
</select>
|
|
1814
|
-
</div>
|
|
1815
|
-
<div style={{ display: "flex", flexDirection: "column", gap: "0.25rem", flex: 1, minWidth: "260px" }}>
|
|
1816
|
-
{/* 候補一覧が引けているなら選ばせる。手入力は綴り違いを「後で赤いバナー」に変える */}
|
|
1817
|
-
{/* ⚠️ 「読めたが 1 件も無い」なら手入力へ倒す (選択欄へ倒すと、選べない選択欄と
|
|
1818
|
-
押せないボタンという終端になり、手入力欄まで消える)。行が在るなら選択欄を
|
|
1819
|
-
出すが、そのうち選べるものが 0 件のときは理由を添える — 作成対話と同じ扱い */}
|
|
1820
|
-
{data.catalog.kind === "ready" && data.catalog.groups.length > 0 ? (
|
|
1821
|
-
<>
|
|
1822
|
-
<div id="directory-external-key-label" style={fieldLabel}>
|
|
1823
|
-
{L.externalKey}
|
|
1824
|
-
</div>
|
|
1825
|
-
{/* biome-ignore lint/a11y/noStaticElementInteractions: 子の暗黙送信を止めるためだけの捕捉であり、対話要素ではない */}
|
|
1826
|
-
<div
|
|
1827
|
-
data-testid="directory-external-key-picker"
|
|
1828
|
-
onKeyDown={(event) => {
|
|
1829
|
-
// 絞り込みを打った直後の Enter が、前回選んだ上流のまま連携を作ってしまう
|
|
1830
|
-
// ⚠️ 変換中の Enter には触れない。日本語 IME の確定は
|
|
1831
|
-
// `keydown{key:"Enter", keyCode:229, isComposing:true}` として届き、
|
|
1832
|
-
// ここで既定動作を止めると変換の確定そのものを潰し得る。
|
|
1833
|
-
// 変換中はそもそも暗黙の送信は起きないので、止める意味も無い
|
|
1834
|
-
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return
|
|
1835
|
-
if (event.key === "Enter") event.preventDefault()
|
|
1836
|
-
}}>
|
|
1837
|
-
<SelectBox
|
|
1838
|
-
options={catalogOptions}
|
|
1839
|
-
// 選んだ後も **名前とアドレス** を見せる。生のアドレスだけに戻すと、
|
|
1840
|
-
// 名前で選んだ運用者に機械的なキーを突き返すことになる
|
|
1841
|
-
value={pickedExternalId === "" ? null : (catalogOptions.find((o) => o.value === pickedExternalId) ?? null)}
|
|
1842
|
-
onChange={(picked: Option | Option[] | null) => {
|
|
1843
|
-
if (picked === null || Array.isArray(picked)) {
|
|
1844
|
-
setPickedExternalId("")
|
|
1845
|
-
setExternalKey("")
|
|
1846
|
-
return
|
|
1847
|
-
}
|
|
1848
|
-
setPickedExternalId(String(picked.value))
|
|
1849
|
-
setExternalKey((picked as UpstreamOption).entry.externalKey)
|
|
1850
|
-
}}
|
|
1851
|
-
placeholder={L.pickUpstreamPlaceholder}
|
|
1852
|
-
ariaLabelledBy="directory-external-key-label"
|
|
1853
|
-
renderOption={renderUpstreamOption}
|
|
1854
|
-
texts={pickerTexts(labels)}
|
|
1855
|
-
clearable
|
|
1856
|
-
/>
|
|
1857
|
-
</div>
|
|
1858
|
-
{selectableCatalogCount === 0 && (
|
|
1859
|
-
<span data-testid="directory-all-upstream-linked" style={{ fontSize: "0.72rem", color: "#92400e" }}>
|
|
1860
|
-
{labels.groupsView.allUpstreamLinked}
|
|
1861
|
-
</span>
|
|
1862
|
-
)}
|
|
1863
|
-
<CatalogFreshness observedAt={data.catalog.observedAt} isStale={data.catalog.isStale} labels={labels} />
|
|
1864
|
-
</>
|
|
1865
|
-
) : (
|
|
1866
|
-
<>
|
|
1867
|
-
<label htmlFor="directory-external-key-input" style={fieldLabel}>
|
|
1868
|
-
{L.externalKey}
|
|
1869
|
-
</label>
|
|
1870
|
-
<input
|
|
1871
|
-
id="directory-external-key-input"
|
|
1872
|
-
type="email"
|
|
1873
|
-
required
|
|
1874
|
-
data-testid="directory-external-key"
|
|
1875
|
-
placeholder={L.externalKeyPlaceholder}
|
|
1876
|
-
value={externalKey}
|
|
1877
|
-
onChange={(e) => {
|
|
1878
|
-
// 手入力は識別子を証明できない。控えの安定 ID は捨て、
|
|
1879
|
-
// サーバー側にもアドレスで解決させる
|
|
1880
|
-
setPickedExternalId("")
|
|
1881
|
-
setExternalKey(e.target.value)
|
|
1882
|
-
}}
|
|
1883
|
-
style={{ padding: "0.5rem", borderRadius: "6px", border: "1px solid #cbd5e1" }}
|
|
1884
|
-
/>
|
|
1885
|
-
{data.catalog.kind === "unavailable" && (
|
|
1886
|
-
<span data-testid="directory-catalog-unavailable" style={{ fontSize: "0.72rem", color: "#92400e" }}>
|
|
1887
|
-
{fill(labels.groupsView.catalogUnavailable, { reason: labels.groupsView.catalogFailures[data.catalog.reason] })}
|
|
1888
|
-
</span>
|
|
1889
|
-
)}
|
|
1890
|
-
{data.catalog.kind === "ready" && (
|
|
1891
|
-
<span data-testid="directory-catalog-empty" style={{ fontSize: "0.72rem", color: "#475569" }}>
|
|
1892
|
-
{fill(labels.groupsView.catalogEmpty, { at: formatDateTime(data.catalog.observedAt, labels) })}
|
|
1893
|
-
</span>
|
|
1894
|
-
)}
|
|
1895
|
-
</>
|
|
1896
|
-
)}
|
|
1897
|
-
</div>
|
|
1898
|
-
<div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
|
1899
|
-
<label htmlFor="directory-membership-mode-input" style={fieldLabel}>
|
|
1900
|
-
{L.membershipMode}
|
|
1901
|
-
</label>
|
|
1902
|
-
<select
|
|
1903
|
-
id="directory-membership-mode-input"
|
|
1904
|
-
data-testid="directory-membership-mode"
|
|
1905
|
-
value={membershipMode}
|
|
1906
|
-
onChange={(e) => setMembershipMode(e.target.value as AuthDirectoryMembershipMode)}
|
|
1907
|
-
style={{ padding: "0.5rem", borderRadius: "6px", border: "1px solid #cbd5e1" }}>
|
|
1908
|
-
<option value="direct">{L.directMode}</option>
|
|
1909
|
-
<option value="transitive">{L.transitiveMode}</option>
|
|
1910
|
-
</select>
|
|
1911
|
-
</div>
|
|
1912
|
-
<button
|
|
1913
|
-
type="submit"
|
|
1914
|
-
data-testid="directory-link-submit"
|
|
1915
|
-
disabled={!(groupId && externalKey) || busy}
|
|
1916
|
-
aria-busy={busy}
|
|
1917
|
-
style={{ padding: "0.5rem 1rem", borderRadius: "6px", border: "none", backgroundColor: groupId && externalKey && !busy ? "#2563eb" : "#94a3b8", color: "#fff", fontWeight: 600 }}>
|
|
1918
|
-
{L.link}
|
|
1919
|
-
</button>
|
|
1920
|
-
</form>
|
|
1921
|
-
)}
|
|
1922
|
-
|
|
1923
|
-
{linked.length === 0 ? (
|
|
1924
|
-
<div data-testid="directory-empty" style={{ padding: "2rem", textAlign: "center", color: "#64748b", backgroundColor: "#ffffff", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
|
|
1925
|
-
{L.empty}
|
|
1926
|
-
</div>
|
|
1927
|
-
) : (
|
|
1928
|
-
<table data-testid="directory-table" style={{ width: "100%", borderCollapse: "collapse", backgroundColor: "#ffffff", borderRadius: "8px", overflow: "hidden", border: "1px solid #e2e8f0" }}>
|
|
1929
|
-
<thead>
|
|
1930
|
-
<tr style={{ backgroundColor: "#f8fafc", textAlign: "left" }}>
|
|
1931
|
-
<th scope="col" style={cell}>
|
|
1932
|
-
{labels.groupsView.name}
|
|
1933
|
-
</th>
|
|
1934
|
-
<th scope="col" style={cell}>
|
|
1935
|
-
{L.externalKey}
|
|
1936
|
-
</th>
|
|
1937
|
-
<th scope="col" style={cell}>
|
|
1938
|
-
{L.membershipMode}
|
|
1939
|
-
</th>
|
|
1940
|
-
<th scope="col" style={cell}>
|
|
1941
|
-
{L.lastSynced}
|
|
1942
|
-
</th>
|
|
1943
|
-
<th scope="col" style={cell}>
|
|
1944
|
-
{labels.common.status}
|
|
1945
|
-
</th>
|
|
1946
|
-
<th scope="col" style={cell}>
|
|
1947
|
-
{labels.common.actions}
|
|
1948
|
-
</th>
|
|
1949
|
-
</tr>
|
|
1950
|
-
</thead>
|
|
1951
|
-
<tbody>
|
|
1952
|
-
{linked.map((g) => {
|
|
1953
|
-
const link = g.externalLink as NonNullable<AuthGroup["externalLink"]>
|
|
1954
|
-
const allowed = allowedGroupIds.has(g.id)
|
|
1955
|
-
return (
|
|
1956
|
-
<tr key={g.id} data-testid={`directory-row-${g.id}`} data-sync-enabled={String(link.isSyncEnabled)} style={{ borderTop: "1px solid #e2e8f0" }}>
|
|
1957
|
-
<td style={cell}>
|
|
1958
|
-
{g.name}
|
|
1959
|
-
<div style={{ fontSize: "0.75rem", color: "#64748b" }}>{g.groupKey}</div>
|
|
1960
|
-
</td>
|
|
1961
|
-
<td style={cell} data-testid={`directory-external-${g.id}`}>
|
|
1962
|
-
{link.externalKey}
|
|
1963
|
-
</td>
|
|
1964
|
-
<td style={cell}>{link.membershipMode === "direct" ? L.directMode : L.transitiveMode}</td>
|
|
1965
|
-
<td style={cell} data-testid={`directory-synced-${g.id}`}>
|
|
1966
|
-
{link.syncedAt ? formatDateTime(link.syncedAt, labels) : L.neverSynced}
|
|
1967
|
-
{link.unresolvedCount > 0 ? (
|
|
1968
|
-
<div style={{ fontSize: "0.75rem", color: "#b45309" }} data-testid={`directory-unresolved-${g.id}`}>
|
|
1969
|
-
{fill(L.unresolvedTemplate, { count: link.unresolvedCount })}
|
|
1970
|
-
</div>
|
|
1971
|
-
) : null}
|
|
1972
|
-
</td>
|
|
1973
|
-
<td style={cell}>
|
|
1974
|
-
<span
|
|
1975
|
-
data-testid={`directory-status-${g.id}`}
|
|
1976
|
-
style={{ fontSize: "0.75rem", padding: "2px 8px", borderRadius: "10px", backgroundColor: link.isSyncEnabled ? "#dcfce7" : "#fee2e2", color: link.isSyncEnabled ? "#166534" : "#b91c1c", fontWeight: 600 }}>
|
|
1977
|
-
{link.isSyncEnabled ? L.syncEnabled : L.syncPaused}
|
|
1978
|
-
</span>
|
|
1979
|
-
{link.syncError ? (
|
|
1980
|
-
<div style={{ fontSize: "0.75rem", color: "#b91c1c", marginTop: "0.25rem" }} data-testid={`directory-error-${g.id}`}>
|
|
1981
|
-
{link.syncError}
|
|
1982
|
-
</div>
|
|
1983
|
-
) : null}
|
|
1984
|
-
</td>
|
|
1985
|
-
{/* td 自体を flex にすると表セルの役割が失われ、列と見出しの対応が切れる */}
|
|
1986
|
-
<td style={cell}>
|
|
1987
|
-
<div style={{ display: "flex", gap: "0.35rem", flexWrap: "wrap" }}>
|
|
1988
|
-
<button
|
|
1989
|
-
type="button"
|
|
1990
|
-
data-testid={`directory-sync-now-${g.id}`}
|
|
1991
|
-
{...rowActionProps(busy)}
|
|
1992
|
-
aria-label={fill(queuedGroupId === g.id ? L.syncQueuedAria : L.syncNowAria, { group: g.name })}
|
|
1993
|
-
onClick={() =>
|
|
1994
|
-
submitThen({ intent: "requestDirectorySync", groupId: String(g.id) }, (result) => {
|
|
1995
|
-
// ⚠️ 積まれたときだけ控えを出す。既に待っている要求があって積み増さなかった場合も
|
|
1996
|
-
// 受理として返るため、区別せずに控えを出すと、押した本人には「要求しました」と
|
|
1997
|
-
// 見えるのに、その下の説明文は「何も積まれていません」と言う画面になる
|
|
1998
|
-
if ((result as { syncRequest?: unknown } | undefined)?.syncRequest !== "queued") return
|
|
1999
|
-
setQueued({ groupId: g.id, syncedAtAtPress: g.externalLink?.syncedAt ?? null })
|
|
2000
|
-
})
|
|
2001
|
-
}
|
|
2002
|
-
style={smallButton}>
|
|
2003
|
-
{queuedGroupId === g.id ? L.syncQueued : L.syncNow}
|
|
2004
|
-
</button>
|
|
2005
|
-
<button
|
|
2006
|
-
type="button"
|
|
2007
|
-
data-testid={`directory-toggle-sync-${g.id}`}
|
|
2008
|
-
{...rowActionProps(busy)}
|
|
2009
|
-
aria-label={fill(link.isSyncEnabled ? L.pauseAria : L.resumeAria, { group: g.name })}
|
|
2010
|
-
onClick={() => {
|
|
2011
|
-
setQueued(null)
|
|
2012
|
-
submitThen({ intent: "setDirectorySyncEnabled", groupId: String(g.id), isSyncEnabled: String(!link.isSyncEnabled) })
|
|
2013
|
-
}}
|
|
2014
|
-
style={smallButton}>
|
|
2015
|
-
{link.isSyncEnabled ? L.pause : L.resume}
|
|
2016
|
-
</button>
|
|
2017
|
-
<button
|
|
2018
|
-
type="button"
|
|
2019
|
-
data-testid={`directory-allow-${g.id}`}
|
|
2020
|
-
{...rowActionProps(busy)}
|
|
2021
|
-
aria-label={fill(allowed ? L.disallowGroupAria : L.allowGroupAria, { group: g.name })}
|
|
2022
|
-
onClick={() => {
|
|
2023
|
-
const entry = allowlistEntryOf(g.id)
|
|
2024
|
-
if (allowed && entry) {
|
|
2025
|
-
// 取り消しはサインインを止める。追加は元に戻すだけなので確認しない
|
|
2026
|
-
if (!window.confirm(fill(L.confirmDisallow, { group: g.name }))) return
|
|
2027
|
-
submitThen({ intent: "removeAllowlistGroup", entryId: String(entry.id) })
|
|
2028
|
-
} else {
|
|
2029
|
-
submitThen({ intent: "addAllowlistGroup", groupId: String(g.id) })
|
|
2030
|
-
}
|
|
2031
|
-
}}
|
|
2032
|
-
style={smallButton}>
|
|
2033
|
-
{allowed ? L.disallowGroup : L.allowGroup}
|
|
2034
|
-
</button>
|
|
2035
|
-
<button
|
|
2036
|
-
type="button"
|
|
2037
|
-
data-testid={`directory-unlink-${g.id}`}
|
|
2038
|
-
{...rowActionProps(busy)}
|
|
2039
|
-
aria-label={fill(L.unlinkAria, { group: g.name })}
|
|
2040
|
-
onClick={() => {
|
|
2041
|
-
// 台帳とグループ単位の許可が失われる。取り消せないので必ず確認する
|
|
2042
|
-
if (!window.confirm(fill(L.confirmUnlink, { group: g.name, upstream: g.externalLink?.externalKey ?? "" }))) return
|
|
2043
|
-
submitThen({ intent: "unlinkDirectoryGroup", groupId: String(g.id) })
|
|
2044
|
-
}}
|
|
2045
|
-
style={{ ...smallButton, color: "#b91c1c", borderColor: "#fecaca" }}>
|
|
2046
|
-
{L.unlink}
|
|
2047
|
-
</button>
|
|
2048
|
-
</div>
|
|
2049
|
-
</td>
|
|
2050
|
-
</tr>
|
|
2051
|
-
)
|
|
2052
|
-
})}
|
|
2053
|
-
</tbody>
|
|
2054
|
-
</table>
|
|
2055
|
-
)}
|
|
2056
|
-
</section>
|
|
2057
|
-
)
|
|
2058
|
-
}
|
|
2059
|
-
|
|
2060
|
-
/** フォーム項目の見出し。プレースホルダは入力すると消えるため名前にならない。 */
|
|
2061
|
-
const fieldLabel: React.CSSProperties = { fontSize: "0.78rem", color: "#475569", fontWeight: 600 }
|
|
2062
|
-
|
|
2063
|
-
/**
|
|
2064
|
-
* 目には出さないが、読み上げと焦点には残す指定。
|
|
2065
|
-
*
|
|
2066
|
-
* `display: none` や `visibility: hidden` はアクセシビリティツリーからも消えるため使えない。
|
|
2067
|
-
* ここで隠すのはラジオ入力そのもので、隠しても **焦点は当たり続けなければならない** (矢印キーで
|
|
2068
|
-
* 移動できるのはブラウザが焦点を持てるからである)。
|
|
2069
|
-
*/
|
|
2070
|
-
const visuallyHidden: React.CSSProperties = { position: "absolute", width: "1px", height: "1px", padding: 0, margin: "-1px", overflow: "hidden", clipPath: "inset(50%)", whiteSpace: "nowrap", border: 0 }
|
|
2071
|
-
/** 各ビューの見出し。段階を飛ばさないよう h2 に固定する。 */
|
|
2072
|
-
const heading: React.CSSProperties = { margin: 0, fontSize: "1.05rem", fontWeight: 700, color: "#0f172a" }
|
|
2073
|
-
/** 表セルの共通スタイル。 */
|
|
2074
|
-
const cell: React.CSSProperties = { padding: "0.6rem 0.75rem", fontSize: "0.85rem", verticalAlign: "top" }
|
|
2075
|
-
/** 行内の小さな操作ボタン。 */
|
|
2076
|
-
/**
|
|
2077
|
-
* Marks a row action as unavailable without taking it out of the tab order.
|
|
2078
|
-
* 行の操作を「今は使えない」と示しつつ、タブ順からは外さない処理。
|
|
2079
|
-
*
|
|
2080
|
-
* ⚠️ `disabled` を使わない。HTML 上の無効化は **焦点を持てない要素** にするため、キーボードで
|
|
2081
|
-
* 押した瞬間にその要素が無効になり、ブラウザは焦点を `<body>` へ落とす。しかも busy は表全体で
|
|
2082
|
-
* 共有しているので、1 行を押しただけで表中の全ボタンが焦点を失う — 応答が返ったあと、操作者は
|
|
2083
|
-
* 文書の先頭から Tab で戻ることになる。`aria-disabled` は焦点を残したまま「使えない」を伝える。
|
|
2084
|
-
* 実際に押させないのは呼び出し側の早期 return である。
|
|
2085
|
-
*
|
|
2086
|
-
* @param busy - Whether a submission is in flight. 送信中かどうか。
|
|
2087
|
-
* @returns Props to spread onto the button. ボタンへ展開する属性。
|
|
2088
|
-
*/
|
|
2089
|
-
function rowActionProps(busy: boolean): { "aria-disabled": boolean; "aria-busy": boolean; "data-busy": string | undefined } {
|
|
2090
|
-
return { "aria-disabled": busy, "aria-busy": busy, "data-busy": busy ? "true" : undefined }
|
|
2091
|
-
}
|
|
2092
|
-
|
|
2093
|
-
const smallButton: React.CSSProperties = { padding: "0.25rem 0.6rem", fontSize: "0.75rem", borderRadius: "6px", border: "1px solid #cbd5e1", backgroundColor: "#fff", cursor: "pointer" }
|
|
2094
|
-
|
|
2095
|
-
const SEGMENT_VIEWS: Record<string, () => React.ReactElement> = {
|
|
2096
|
-
users: UsersView,
|
|
2097
|
-
groups: GroupsView,
|
|
2098
|
-
group_members: GroupMembersView,
|
|
2099
|
-
allowlist: AllowlistView,
|
|
2100
|
-
directory: DirectoryView,
|
|
2101
|
-
}
|
|
2102
|
-
|
|
2103
|
-
export type AuthUserAdminAppViewProps = UserAdminShellProps
|
|
2104
|
-
|
|
2105
|
-
/**
|
|
2106
|
-
* Top-level application view component for the user administration UI (`/users/*`).
|
|
2107
|
-
* ユーザー管理 UI 全体を描画するトップレベルコンポーネント。
|
|
2108
|
-
*/
|
|
2109
|
-
export function AuthUserAdminAppView({ renderHeader, pendingSyncPolling }: AuthUserAdminAppViewProps = {}) {
|
|
2110
|
-
const data = useLoaderData() as UserAdminLoaderData | undefined
|
|
2111
|
-
const segment = data?.segment ?? "users"
|
|
2112
|
-
// 素のオブジェクト参照だと `segment="constructor"` が真値を返し、画面が描けなくなる
|
|
2113
|
-
const View = Object.hasOwn(SEGMENT_VIEWS, segment) ? SEGMENT_VIEWS[segment] : UsersView
|
|
2114
|
-
|
|
2115
|
-
return (
|
|
2116
|
-
<UserAdminShell renderHeader={renderHeader} pendingSyncPolling={pendingSyncPolling}>
|
|
2117
|
-
<View />
|
|
2118
|
-
</UserAdminShell>
|
|
2119
|
-
)
|
|
2120
|
-
}
|