@aiquants/auth-react-router 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,8 @@
1
- import type { AuthAllowlistEntry, AuthDirectoryStatus, AuthGroup, AuthGroupMember, AuthUser } from "@aiquants/auth-core"
1
+ import type { AuthAllowlistEntry, AuthDirectoryMembershipMode, AuthDirectoryStatus, AuthGroup, AuthGroupMember, AuthUser } from "@aiquants/auth-core"
2
2
  import type React from "react"
3
- import { useState } from "react"
3
+ import { useEffect, useRef, useState } from "react"
4
4
  import { useFetcher, useLoaderData } from "react-router"
5
+ import { fill, formatDateTime } from "./format"
5
6
  import { defaultUserAdminLabels, type UserAdminLabels } from "./labels"
6
7
  import { UserAdminShell, type UserAdminShellProps } from "./ui"
7
8
 
@@ -26,63 +27,193 @@ export type UserAdminLoaderData = {
26
27
  * ロックアウト理由・重複パターン・入力不正などの文言は action が `{ok:false,error}` で返す。
27
28
  * 表示しなければ操作が黙って失敗したように見えるため、各ビューの先頭に必ず置く。
28
29
  */
29
- function ActionError({ fetcher }: { fetcher: { data?: unknown } }) {
30
- const result = fetcher.data as { ok?: boolean; error?: string } | undefined
30
+ function ActionError({ result: raw, id }: { result: unknown; id?: string }) {
31
+ const result = raw as { ok?: boolean; error?: string } | undefined
31
32
  if (result?.ok !== false || !result.error) return null
32
33
  return (
33
- <div data-testid="user-admin-action-error" role="alert" style={{ padding: "0.7rem 1rem", borderRadius: "6px", border: "1px solid #fecaca", backgroundColor: "#fef2f2", color: "#b91c1c", fontSize: "0.85rem" }}>
34
+ // グリッド配下でも行を跨ぐ。空のときは描かないので、余分な行も生まれない
35
+ <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" }}>
34
36
  {result.error}
35
37
  </div>
36
38
  )
37
39
  }
38
40
 
41
+ /**
42
+ * Submits through the view's single fetcher and defers "close and clear" until the server accepts.
43
+ * ビュー唯一の fetcher で送信し、**サーバーが受理してから** 「閉じて消す」を行うためのフック。
44
+ *
45
+ * 送信直後に閉じて入力を消すと、認可拒否や検証エラーで戻ったときに打ち込んだ内容が丸ごと
46
+ * 失われ、利用者は全部打ち直すことになる。成功応答 (`ok === true`) を待って初めて破棄する。
47
+ *
48
+ * ⚠️ **このビューの送信はすべてここを通すこと。** fetcher は 1 つを共有しており、後の送信は
49
+ * 前の送信を打ち切る。素の `fetcher.submit` を混ぜると、打ち切られた送信の後始末が **後の
50
+ * 送信の成功応答で** 実行される (行の操作を挟んだだけで、フォームの入力が消える)。ここを
51
+ * 通せば後の送信が `pending` を必ず上書きするため、後始末は常に最後の送信のものになる。
52
+ */
53
+ function useSubmitThen(fetcher: ReturnType<typeof useFetcher>) {
54
+ const pending = useRef<(() => void) | null>(null)
55
+ // 破棄した結果の世代。`fetcher.data` は宣言的に消せないため、こちらで無効化する
56
+ const [discarded, setDiscarded] = useState<unknown>(undefined)
57
+ const result = fetcher.data === discarded ? undefined : fetcher.data
58
+
59
+ useEffect(() => {
60
+ if (fetcher.state !== "idle" || fetcher.data === undefined) return
61
+ const onSuccess = pending.current
62
+ if (!onSuccess) return
63
+ // 同じ応答で 2 度実行しないよう、消費した時点で捨てる。ここは **冗長な防御** である —
64
+ // すべての送信がこのフックを通る限り、後の送信が必ず `pending` を上書きするため。
65
+ // 素の `fetcher.submit` が 1 つでも混ざった瞬間に効き始める
66
+ pending.current = null
67
+ if ((fetcher.data as { ok?: boolean }).ok === true) onSuccess()
68
+ }, [fetcher.state, fetcher.data])
69
+
70
+ const submit = (payload: Record<string, string>, onSuccess?: () => void) => {
71
+ pending.current = onSuccess ?? null
72
+ setDiscarded(undefined)
73
+ fetcher.submit(payload, { method: "post" })
74
+ }
75
+ /**
76
+ * Forgets the last result so an unrelated failure cannot describe a freshly opened dialog.
77
+ * 直前の結果を忘れる処理 (無関係な失敗が、開いたばかりのダイアログの説明文にならないように)。
78
+ */
79
+ const resetResult = () => setDiscarded(fetcher.data)
80
+
81
+ return { submit, resetResult, result }
82
+ }
83
+
84
+ /**
85
+ * Gives a dialog the behavior its ARIA attributes promise: focus, trap, Escape, restore.
86
+ * ダイアログに、ARIA 属性が約束している挙動 (初期焦点・封じ込め・Escape・復帰) を与えるフック。
87
+ *
88
+ * 役割属性だけを付けても、焦点は背後に残り、Tab は外へ出て行き、Escape は効かない。
89
+ * キーボード利用者は「閉じる」に到達できず、支援技術は背後を読み続ける。
90
+ *
91
+ * @param isOpen Whether the dialog is mounted and visible. ダイアログが開いているかどうか。
92
+ * @param onClose Invoked when Escape is pressed. Escape が押されたときに呼ばれる処理。
93
+ * @returns Ref to attach to the dialog panel. ダイアログ本体へ付ける ref。
94
+ */
95
+ function useModalBehavior(isOpen: boolean, onClose: () => void, openerRef?: React.RefObject<HTMLElement | null>) {
96
+ const ref = useRef<HTMLFormElement>(null)
97
+ // 再描画のたびに実行されると入力中に焦点が先頭へ飛ぶため、依存は開閉だけにする
98
+ const closeRef = useRef(onClose)
99
+ closeRef.current = onClose
100
+
101
+ useEffect(() => {
102
+ if (!isOpen) return
103
+ const panel = ref.current
104
+ // `activeElement` を見るだけでは戻し先にならない。押下で焦点が乗らないブラウザ
105
+ // (Safari / macOS の Firefox) では body へ戻ってしまう。開いた要素を明示的に覚える
106
+ const opener = openerRef?.current ?? (document.activeElement as HTMLElement | null)
107
+ const focusables = () => Array.from(panel?.querySelectorAll<HTMLElement>('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])') ?? [])
108
+ focusables()[0]?.focus()
109
+
110
+ const onKeyDown = (event: KeyboardEvent) => {
111
+ if (event.key === "Escape") {
112
+ // 変換中の Escape は「候補を取り消す」であって「閉じる」ではない。ここで
113
+ // 閉じると、編集中の下書きは状態ごと捨てられ、打ち直しになる
114
+ if (event.isComposing || event.keyCode === 229) return
115
+ event.preventDefault()
116
+ closeRef.current()
117
+ return
118
+ }
119
+ if (event.key !== "Tab") return
120
+ const items = focusables()
121
+ if (items.length === 0) return
122
+ const first = items[0]
123
+ const last = items[items.length - 1]
124
+ const active = document.activeElement
125
+ const outside = !panel?.contains(active)
126
+ if (event.shiftKey && (active === first || outside)) {
127
+ event.preventDefault()
128
+ last.focus()
129
+ } else if (!event.shiftKey && (active === last || outside)) {
130
+ event.preventDefault()
131
+ first.focus()
132
+ }
133
+ }
134
+ document.addEventListener("keydown", onKeyDown)
135
+ return () => {
136
+ document.removeEventListener("keydown", onKeyDown)
137
+ opener?.focus?.()
138
+ }
139
+ }, [isOpen, openerRef])
140
+
141
+ return ref
142
+ }
143
+
39
144
  export function UsersView() {
40
145
  const data = useLoaderData() as UserAdminLoaderData
41
146
  const fetcher = useFetcher()
147
+ const { submit: submitThen, resetResult, result: actionResult } = useSubmitThen(fetcher)
42
148
  const labels = data.labels ?? defaultUserAdminLabels
43
149
 
44
150
  const [searchQuery, setSearchQuery] = useState("")
45
- const [editingUser, setEditingUser] = useState<AuthUser | null>(null)
46
- const [isCreateOpen, setIsCreateOpen] = useState(false)
151
+ // 2 つの独立した状態にすると「両方開いている」状態が表現でき、エラー欄が 2 つ描かれる。
152
+ // 排他であることを型で持つ
153
+ const [modal, setModal] = useState<{ kind: "create" } | { kind: "edit"; user: AuthUser } | null>(null)
154
+ const editingUser = modal?.kind === "edit" ? modal.user : null
155
+ const isCreateOpen = modal?.kind === "create"
156
+ const openerRef = useRef<HTMLElement | null>(null)
157
+ /** ダイアログを開く。直前の操作の結果を持ち越さない (別の操作の拒否が説明文になる)。 */
158
+ const openModal = (next: { kind: "create" } | { kind: "edit"; user: AuthUser }, event: React.MouseEvent<HTMLElement>) => {
159
+ openerRef.current = event.currentTarget
160
+ resetResult()
161
+ setModal(next)
162
+ }
47
163
 
48
164
  const [newDisplayName, setNewDisplayName] = useState("")
49
165
  const [newEmail, setNewEmail] = useState("")
166
+ const createUserRef = useModalBehavior(isCreateOpen, () => setModal(null), openerRef)
167
+ const editUserRef = useModalBehavior(editingUser !== null, () => setModal(null), openerRef)
50
168
 
169
+ const busy = fetcher.state !== "idle"
51
170
  const filteredUsers = data.users.filter((u) => u.displayName.toLowerCase().includes(searchQuery.toLowerCase()) || u.email.toLowerCase().includes(searchQuery.toLowerCase()))
52
171
 
53
172
  const handleToggleActive = (user: AuthUser) => {
54
- fetcher.submit({ intent: "toggleUserActive", userId: String(user.id), isActive: String(!user.isActive) }, { method: "post" })
173
+ // 無効化はサインインを止める。有効化は元に戻すだけなので確認しない
174
+ if (user.isActive && !window.confirm(labels.usersView.confirmDeactivate)) return
175
+ submitThen({ intent: "toggleUserActive", userId: String(user.id), isActive: String(!user.isActive) })
55
176
  }
56
177
 
57
178
  const handleCreateUser = (e: React.FormEvent) => {
58
179
  e.preventDefault()
59
180
  if (!(newDisplayName && newEmail)) return
60
- fetcher.submit({ intent: "createUser", displayName: newDisplayName, email: newEmail }, { method: "post" })
61
- setNewDisplayName("")
62
- setNewEmail("")
63
- setIsCreateOpen(false)
181
+ // 送信した値と今の値が違うなら、往復の間に打ち込まれたものである。消してはならない
182
+ const sentName = newDisplayName
183
+ const sentEmail = newEmail
184
+ submitThen({ intent: "createUser", displayName: sentName, email: sentEmail }, () => {
185
+ setNewDisplayName((current) => (current === sentName ? "" : current))
186
+ setNewEmail((current) => (current === sentEmail ? "" : current))
187
+ setModal(null)
188
+ })
64
189
  }
65
190
 
66
191
  const handleUpdateUser = (e: React.FormEvent) => {
67
192
  e.preventDefault()
68
193
  if (!editingUser) return
69
- fetcher.submit({ intent: "updateUser", userId: String(editingUser.id), displayName: editingUser.displayName, email: editingUser.email }, { method: "post" })
70
- setEditingUser(null)
194
+ submitThen({ intent: "updateUser", userId: String(editingUser.id), displayName: editingUser.displayName, email: editingUser.email }, () => setModal(null))
71
195
  }
72
196
 
73
197
  return (
74
198
  <section data-testid="users-view-section" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
75
- <ActionError fetcher={fetcher} />
199
+ <h2 style={heading}>{labels.usersView.title}</h2>
200
+ {/* モーダルが開いている間はモーダル内に描く。両方描くと同じ hook が 2 つになる */}
201
+ {!modal && <ActionError result={actionResult} />}
76
202
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", backgroundColor: "#ffffff", padding: "1rem", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
77
203
  <input
78
204
  type="text"
205
+ aria-label={labels.usersView.searchLabel}
79
206
  placeholder={labels.usersView.searchPlaceholder}
80
207
  value={searchQuery}
81
208
  onChange={(e) => setSearchQuery(e.target.value)}
82
209
  data-testid="users-search-input"
83
210
  style={{ padding: "6px 12px", borderRadius: "6px", border: "1px solid #cbd5e1", fontSize: "0.85rem", width: "260px" }}
84
211
  />
85
- <button type="button" onClick={() => setIsCreateOpen(true)} data-testid="users-add-button" style={{ padding: "6px 14px", borderRadius: "6px", backgroundColor: "#0284c7", color: "#ffffff", border: "none", fontSize: "0.85rem", fontWeight: 600, cursor: "pointer" }}>
212
+ <button
213
+ type="button"
214
+ onClick={(event) => openModal({ kind: "create" }, event)}
215
+ data-testid="users-add-button"
216
+ style={{ padding: "6px 14px", borderRadius: "6px", backgroundColor: "#0284c7", color: "#ffffff", border: "none", fontSize: "0.85rem", fontWeight: 600, cursor: "pointer" }}>
86
217
  {labels.usersView.addUser}
87
218
  </button>
88
219
  </div>
@@ -91,17 +222,17 @@ export function UsersView() {
91
222
  <table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.85rem", textAlign: "left" }}>
92
223
  <thead>
93
224
  <tr style={{ backgroundColor: "#f8fafc", borderBottom: "1px solid #e2e8f0", color: "#475569" }}>
94
- <th style={{ padding: "10px 14px" }}>{labels.usersView.id}</th>
225
+ <th style={{ padding: "10px 14px" }}>{labels.common.id}</th>
95
226
  <th style={{ padding: "10px 14px" }}>{labels.usersView.displayName}</th>
96
227
  <th style={{ padding: "10px 14px" }}>{labels.usersView.email}</th>
97
- <th style={{ padding: "10px 14px" }}>{labels.usersView.status}</th>
98
- <th style={{ padding: "10px 14px", textAlign: "right" }}>{labels.usersView.actions}</th>
228
+ <th style={{ padding: "10px 14px" }}>{labels.common.status}</th>
229
+ <th style={{ padding: "10px 14px", textAlign: "right" }}>{labels.common.actions}</th>
99
230
  </tr>
100
231
  </thead>
101
232
  <tbody>
102
233
  {filteredUsers.map((u) => (
103
234
  <tr key={u.id} style={{ borderBottom: "1px solid #f1f5f9" }} data-testid={`user-row-${u.id}`}>
104
- <td style={{ padding: "10px 14px", color: "#64748b" }}>#{u.id}</td>
235
+ <td style={{ padding: "10px 14px", color: "#475569" }}>{fill(labels.common.idTemplate, { id: u.id })}</td>
105
236
  <td style={{ padding: "10px 14px", fontWeight: 600, color: "#0f172a" }}>{u.displayName}</td>
106
237
  <td style={{ padding: "10px 14px", color: "#475569" }}>{u.email}</td>
107
238
  <td style={{ padding: "10px 14px" }}>
@@ -112,16 +243,20 @@ export function UsersView() {
112
243
  <td style={{ padding: "10px 14px", textAlign: "right" }}>
113
244
  <button
114
245
  type="button"
115
- onClick={() => setEditingUser(u)}
246
+ onClick={(event) => openModal({ kind: "edit", user: u }, event)}
247
+ aria-label={fill(labels.usersView.editUserAria, { user: u.displayName })}
116
248
  data-testid={`edit-user-btn-${u.id}`}
117
249
  style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", fontSize: "0.78rem", cursor: "pointer", marginRight: "6px" }}>
118
- {labels.usersView.edit}
250
+ {labels.common.edit}
119
251
  </button>
120
252
  <button
121
253
  type="button"
122
254
  onClick={() => handleToggleActive(u)}
255
+ disabled={busy}
256
+ aria-busy={busy}
257
+ aria-label={fill(u.isActive ? labels.usersView.deactivateAria : labels.usersView.activateAria, { user: u.displayName })}
123
258
  data-testid={`toggle-user-btn-${u.id}`}
124
- style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", fontSize: "0.78rem", cursor: "pointer", color: u.isActive ? "#b91c1c" : "#15803d" }}>
259
+ style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", fontSize: "0.78rem", cursor: busy ? "default" : "pointer", color: u.isActive ? "#b91c1c" : "#15803d" }}>
125
260
  {u.isActive ? labels.usersView.deactivate : labels.usersView.activate}
126
261
  </button>
127
262
  </td>
@@ -129,7 +264,7 @@ export function UsersView() {
129
264
  ))}
130
265
  {filteredUsers.length === 0 && (
131
266
  <tr>
132
- <td colSpan={5} style={{ padding: "2rem", textAlign: "center", color: "#94a3b8" }}>
267
+ <td colSpan={5} style={{ padding: "2rem", textAlign: "center", color: "#475569" }}>
133
268
  {labels.usersView.empty}
134
269
  </td>
135
270
  </tr>
@@ -140,9 +275,20 @@ export function UsersView() {
140
275
 
141
276
  {/* Create Modal */}
142
277
  {isCreateOpen && (
143
- <div style={{ position: "fixed", inset: 0, backgroundColor: "rgba(0,0,0,0.4)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 100 }} data-testid="user-create-modal">
144
- <form onSubmit={handleCreateUser} style={{ backgroundColor: "#ffffff", padding: "1.5rem", borderRadius: "8px", width: "360px", boxShadow: "0 10px 25px rgba(0,0,0,0.15)" }}>
145
- <h3 style={{ margin: "0 0 1rem 0", fontSize: "1.1rem" }}>{labels.usersView.addUser}</h3>
278
+ <div style={{ position: "fixed", inset: 0, backgroundColor: "rgba(0,0,0,0.4)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 100 }}>
279
+ <form
280
+ ref={createUserRef}
281
+ onSubmit={handleCreateUser}
282
+ role="dialog"
283
+ aria-modal="true"
284
+ aria-labelledby="create-user-title"
285
+ aria-describedby="create-user-error"
286
+ data-testid="user-create-modal"
287
+ style={{ backgroundColor: "#ffffff", padding: "1.5rem", borderRadius: "8px", width: "360px", boxShadow: "0 10px 25px rgba(0,0,0,0.15)" }}>
288
+ <h3 id="create-user-title" style={{ margin: "0 0 1rem 0", fontSize: "1.1rem" }}>
289
+ {labels.usersView.addUser}
290
+ </h3>
291
+ <ActionError result={actionResult} id="create-user-error" />
146
292
  <div style={{ marginBottom: "1rem" }}>
147
293
  <label htmlFor="create-user-display-name-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
148
294
  {labels.usersView.displayName}
@@ -172,11 +318,11 @@ export function UsersView() {
172
318
  />
173
319
  </div>
174
320
  <div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
175
- <button type="button" onClick={() => setIsCreateOpen(false)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
176
- キャンセル
321
+ <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" }}>
322
+ {labels.common.cancel}
177
323
  </button>
178
- <button type="submit" data-testid="submit-create-user" style={{ padding: "6px 12px", borderRadius: "4px", backgroundColor: "#0284c7", color: "#ffffff", border: "none", cursor: "pointer" }}>
179
- 作成
324
+ <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" }}>
325
+ {labels.common.create}
180
326
  </button>
181
327
  </div>
182
328
  </form>
@@ -185,9 +331,20 @@ export function UsersView() {
185
331
 
186
332
  {/* Edit Modal */}
187
333
  {editingUser && (
188
- <div style={{ position: "fixed", inset: 0, backgroundColor: "rgba(0,0,0,0.4)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 100 }} data-testid="user-edit-modal">
189
- <form onSubmit={handleUpdateUser} style={{ backgroundColor: "#ffffff", padding: "1.5rem", borderRadius: "8px", width: "360px", boxShadow: "0 10px 25px rgba(0,0,0,0.15)" }}>
190
- <h3 style={{ margin: "0 0 1rem 0", fontSize: "1.1rem" }}>{labels.usersView.editUser}</h3>
334
+ <div style={{ position: "fixed", inset: 0, backgroundColor: "rgba(0,0,0,0.4)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 100 }}>
335
+ <form
336
+ ref={editUserRef}
337
+ onSubmit={handleUpdateUser}
338
+ role="dialog"
339
+ aria-modal="true"
340
+ aria-labelledby="edit-user-title"
341
+ aria-describedby="edit-user-error"
342
+ data-testid="user-edit-modal"
343
+ style={{ backgroundColor: "#ffffff", padding: "1.5rem", borderRadius: "8px", width: "360px", boxShadow: "0 10px 25px rgba(0,0,0,0.15)" }}>
344
+ <h3 id="edit-user-title" style={{ margin: "0 0 1rem 0", fontSize: "1.1rem" }}>
345
+ {labels.usersView.editUser}
346
+ </h3>
347
+ <ActionError result={actionResult} id="edit-user-error" />
191
348
  <div style={{ marginBottom: "1rem" }}>
192
349
  <label htmlFor="edit-user-display-name-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
193
350
  {labels.usersView.displayName}
@@ -196,7 +353,7 @@ export function UsersView() {
196
353
  id="edit-user-display-name-input"
197
354
  type="text"
198
355
  value={editingUser.displayName}
199
- onChange={(e) => setEditingUser({ ...editingUser, displayName: e.target.value })}
356
+ onChange={(e) => setModal({ kind: "edit", user: { ...editingUser, displayName: e.target.value } })}
200
357
  required
201
358
  data-testid="edit-user-display-name"
202
359
  style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box" }}
@@ -210,18 +367,18 @@ export function UsersView() {
210
367
  id="edit-user-email-input"
211
368
  type="email"
212
369
  value={editingUser.email}
213
- onChange={(e) => setEditingUser({ ...editingUser, email: e.target.value })}
370
+ onChange={(e) => setModal({ kind: "edit", user: { ...editingUser, email: e.target.value } })}
214
371
  required
215
372
  data-testid="edit-user-email"
216
373
  style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box" }}
217
374
  />
218
375
  </div>
219
376
  <div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
220
- <button type="button" onClick={() => setEditingUser(null)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
221
- {labels.usersView.cancel}
377
+ <button type="button" onClick={() => setModal(null)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
378
+ {labels.common.cancel}
222
379
  </button>
223
- <button type="submit" data-testid="submit-edit-user" style={{ padding: "6px 12px", borderRadius: "4px", backgroundColor: "#0284c7", color: "#ffffff", border: "none", cursor: "pointer" }}>
224
- {labels.usersView.save}
380
+ <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" }}>
381
+ {labels.common.save}
225
382
  </button>
226
383
  </div>
227
384
  </form>
@@ -235,84 +392,76 @@ export function UsersView() {
235
392
  * View for managing user groups (create, edit, delete).
236
393
  * ユーザーグループ管理ビュー (作成・編集・削除)。
237
394
  */
238
- /**
239
- * Warns when synchronization is not actually keeping the mirror fresh.
240
- * 同期が実際には鏡を新しく保てていないことを警告する部品。
241
- *
242
- * 同期の停止は「上流から外れた人がテナントに居続ける」というセキュリティ事象である。件数と
243
- * エラー数だけを見ると何も起きていないように読めるため、**滞留・全停止・未結線の 3 つを
244
- * それぞれ名指しで** 出す。出さなければ、状態を計算している意味がない。
245
- */
246
- function DirectorySyncBanner({ status, labels }: { status: AuthDirectoryStatus; labels: UserAdminLabels }) {
247
- if (status.linkedGroupCount === 0) return null
248
- const warnings: Array<{ id: string; text: string }> = []
249
- if (!status.isEnabled) warnings.push({ id: "disabled", text: labels.directoryView.disabledWarning })
250
- if (status.pausedGroupCount > 0) warnings.push({ id: "paused", text: `${labels.directoryView.syncPaused}: ${status.pausedGroupCount} / ${status.linkedGroupCount}` })
251
- if (status.isStale) warnings.push({ id: "stale", text: labels.directoryView.staleWarning })
252
- if (status.erroredGroupIds.length > 0) warnings.push({ id: "errored", text: `${labels.directoryView.title}: ${status.erroredGroupIds.join(", ")}` })
253
- if (warnings.length === 0) return null
254
- return (
255
- <div data-testid="directory-sync-banner" role="status" style={{ display: "flex", flexDirection: "column", gap: "0.35rem", padding: "0.7rem 1rem", borderRadius: "6px", border: "1px solid #fde68a", backgroundColor: "#fffbeb", color: "#92400e", fontSize: "0.85rem" }}>
256
- {warnings.map((w) => (
257
- <span key={w.id} data-directory-warning={w.id}>
258
- {w.text}
259
- </span>
260
- ))}
261
- </div>
262
- )
263
- }
264
-
265
395
  export function GroupsView() {
266
396
  const data = useLoaderData() as UserAdminLoaderData
267
397
  const fetcher = useFetcher()
398
+ const { submit: submitThen, resetResult, result: actionResult } = useSubmitThen(fetcher)
268
399
  const labels = data.labels ?? defaultUserAdminLabels
269
400
 
270
401
  const [searchQuery, setSearchQuery] = useState("")
271
- const [isCreateOpen, setIsCreateOpen] = useState(false)
272
- const [editingGroup, setEditingGroup] = useState<AuthGroup | null>(null)
402
+ const [modal, setModal] = useState<{ kind: "create" } | { kind: "edit"; group: AuthGroup } | null>(null)
403
+ const editingGroup = modal?.kind === "edit" ? modal.group : null
404
+ const isCreateOpen = modal?.kind === "create"
405
+ const openerRef = useRef<HTMLElement | null>(null)
406
+ /** ダイアログを開く。直前の操作の結果を持ち越さない (別の操作の拒否が説明文になる)。 */
407
+ const openModal = (next: { kind: "create" } | { kind: "edit"; group: AuthGroup }, event: React.MouseEvent<HTMLElement>) => {
408
+ openerRef.current = event.currentTarget
409
+ resetResult()
410
+ setModal(next)
411
+ }
273
412
 
413
+ const busy = fetcher.state !== "idle"
274
414
  const [newGroupKey, setNewGroupKey] = useState("")
275
415
  const [newName, setNewName] = useState("")
276
416
  const [newDescription, setNewDescription] = useState("")
417
+ const createGroupRef = useModalBehavior(isCreateOpen, () => setModal(null), openerRef)
418
+ const editGroupRef = useModalBehavior(editingGroup !== null, () => setModal(null), openerRef)
277
419
 
278
420
  const filteredGroups = data.groups.filter((g) => g.name.toLowerCase().includes(searchQuery.toLowerCase()) || g.groupKey.toLowerCase().includes(searchQuery.toLowerCase()))
279
421
 
280
422
  const handleCreateGroup = (e: React.FormEvent) => {
281
423
  e.preventDefault()
282
424
  if (!(newGroupKey && newName)) return
283
- fetcher.submit({ intent: "createGroup", groupKey: newGroupKey, name: newName, description: newDescription }, { method: "post" })
284
- setNewGroupKey("")
285
- setNewName("")
286
- setNewDescription("")
287
- setIsCreateOpen(false)
425
+ // 送信した値と今の値が違うなら、往復の間に打ち込まれたものである。消してはならない
426
+ const sent = { key: newGroupKey, name: newName, description: newDescription }
427
+ submitThen({ intent: "createGroup", groupKey: sent.key, name: sent.name, description: sent.description }, () => {
428
+ setNewGroupKey((current) => (current === sent.key ? "" : current))
429
+ setNewName((current) => (current === sent.name ? "" : current))
430
+ setNewDescription((current) => (current === sent.description ? "" : current))
431
+ setModal(null)
432
+ })
288
433
  }
289
434
 
290
435
  const handleUpdateGroup = (e: React.FormEvent) => {
291
436
  e.preventDefault()
292
437
  if (!editingGroup) return
293
- fetcher.submit({ intent: "updateGroup", groupId: String(editingGroup.id), name: editingGroup.name, description: editingGroup.description ?? "" }, { method: "post" })
294
- setEditingGroup(null)
438
+ submitThen({ intent: "updateGroup", groupId: String(editingGroup.id), name: editingGroup.name, description: editingGroup.description ?? "" }, () => setModal(null))
295
439
  }
296
440
 
297
441
  const handleDeleteGroup = (groupId: number) => {
298
442
  if (!window.confirm(labels.groupsView.confirmDelete)) return
299
- fetcher.submit({ intent: "deleteGroup", groupId: String(groupId) }, { method: "post" })
443
+ submitThen({ intent: "deleteGroup", groupId: String(groupId) })
300
444
  }
301
445
 
302
446
  return (
303
447
  <section data-testid="groups-view-section" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
304
- <ActionError fetcher={fetcher} />
305
- <DirectorySyncBanner status={data.directoryStatus} labels={labels} />
448
+ <h2 style={heading}>{labels.groupsView.title}</h2>
449
+ {!modal && <ActionError result={actionResult} />}
306
450
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", backgroundColor: "#ffffff", padding: "1rem", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
307
451
  <input
308
452
  type="text"
453
+ aria-label={labels.groupsView.searchLabel}
309
454
  placeholder={labels.groupsView.searchPlaceholder}
310
455
  value={searchQuery}
311
456
  onChange={(e) => setSearchQuery(e.target.value)}
312
457
  data-testid="groups-search-input"
313
458
  style={{ padding: "6px 12px", borderRadius: "6px", border: "1px solid #cbd5e1", fontSize: "0.85rem", width: "260px" }}
314
459
  />
315
- <button type="button" onClick={() => setIsCreateOpen(true)} data-testid="groups-add-button" style={{ padding: "6px 14px", borderRadius: "6px", backgroundColor: "#4338ca", color: "#ffffff", border: "none", fontSize: "0.85rem", fontWeight: 600, cursor: "pointer" }}>
460
+ <button
461
+ type="button"
462
+ onClick={(event) => openModal({ kind: "create" }, event)}
463
+ data-testid="groups-add-button"
464
+ style={{ padding: "6px 14px", borderRadius: "6px", backgroundColor: "#4338ca", color: "#ffffff", border: "none", fontSize: "0.85rem", fontWeight: 600, cursor: "pointer" }}>
316
465
  {labels.groupsView.addGroup}
317
466
  </button>
318
467
  </div>
@@ -335,41 +484,47 @@ export function GroupsView() {
335
484
  {/* 所属も名称も上流が所有していることを、操作を試す前に伝える */}
336
485
  {g.externalLink && (
337
486
  <div data-testid={`group-source-${g.id}`} style={{ display: "flex", alignItems: "center", gap: "6px", flexWrap: "wrap", marginBottom: "0.6rem" }}>
338
- <span style={{ fontSize: "0.72rem", padding: "2px 8px", borderRadius: "10px", backgroundColor: "#ede9fe", color: "#6d28d9", fontWeight: 600 }}>{labels.directoryView.managedUpstream}</span>
487
+ <span style={{ fontSize: "0.72rem", padding: "2px 8px", borderRadius: "10px", backgroundColor: "#ede9fe", color: "#6d28d9", fontWeight: 600 }}>{labels.common.managedUpstream}</span>
339
488
  <span style={{ fontSize: "0.72rem", fontFamily: "monospace", color: "#64748b" }}>{g.externalLink.externalKey}</span>
340
489
  {g.externalLink.unresolvedCount > 0 && (
341
490
  <span data-testid={`group-unresolved-${g.id}`} style={{ fontSize: "0.72rem", color: "#b45309" }}>
342
- {g.externalLink.unresolvedCount} {labels.directoryView.unresolved}
491
+ {fill(labels.directoryView.unresolvedTemplate, { count: g.externalLink.unresolvedCount })}
343
492
  </span>
344
493
  )}
345
494
  </div>
346
495
  )}
347
496
  </div>
348
497
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", borderTop: "1px solid #f1f5f9", paddingTop: "0.6rem" }}>
349
- <span style={{ fontSize: "0.78rem", color: "#475569" }}>
350
- 👥 <strong>{memberCount}</strong>
498
+ <span style={{ fontSize: "0.78rem", color: "#475569" }} data-testid={`group-member-count-${g.id}`}>
499
+ {fill(labels.groupsView.memberCountTemplate, { count: memberCount })}
351
500
  </span>
352
501
  {/* 上流が所有する行では操作を **描かない**。無効化したボタンを出すと、
353
502
  押せる操作と見分けがつかず、押せない理由も伝わらない */}
354
503
  {g.readOnly ? (
355
- <span data-testid={`readonly-group-${g.id}`} style={{ fontSize: "0.75rem", color: "#94a3b8" }}>
356
- {labels.allowlistView.readOnly}
504
+ <span data-testid={`readonly-group-${g.id}`} style={{ fontSize: "0.75rem", color: "#475569" }}>
505
+ {labels.common.readOnly}
357
506
  </span>
358
507
  ) : (
359
508
  <div>
360
509
  <button
361
510
  type="button"
362
- onClick={() => setEditingGroup(g)}
511
+ onClick={(event) => openModal({ kind: "edit", group: g }, event)}
512
+ disabled={busy}
513
+ aria-busy={busy}
514
+ aria-label={fill(labels.groupsView.editGroupAria, { group: g.name })}
363
515
  data-testid={`edit-group-btn-${g.id}`}
364
516
  style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", fontSize: "0.75rem", cursor: "pointer", marginRight: "4px" }}>
365
- {labels.usersView.edit}
517
+ {labels.common.edit}
366
518
  </button>
367
519
  <button
368
520
  type="button"
369
521
  onClick={() => handleDeleteGroup(g.id)}
522
+ disabled={busy}
523
+ aria-busy={busy}
524
+ aria-label={fill(labels.groupsView.deleteGroupAria, { group: g.name })}
370
525
  data-testid={`delete-group-btn-${g.id}`}
371
526
  style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #fee2e2", backgroundColor: "#fff5f5", color: "#b91c1c", fontSize: "0.75rem", cursor: "pointer" }}>
372
- {labels.allowlistView.delete}
527
+ {labels.common.delete}
373
528
  </button>
374
529
  </div>
375
530
  )}
@@ -378,12 +533,28 @@ export function GroupsView() {
378
533
  )
379
534
  })}
380
535
  </div>
536
+ {filteredGroups.length === 0 && (
537
+ <div data-testid="groups-empty" style={{ padding: "2rem", textAlign: "center", color: "#475569", backgroundColor: "#ffffff", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
538
+ {data.groups.length === 0 ? labels.groupsView.empty : labels.groupsView.emptyFiltered}
539
+ </div>
540
+ )}
381
541
 
382
542
  {/* Create Group Modal */}
383
543
  {isCreateOpen && (
384
- <div style={{ position: "fixed", inset: 0, backgroundColor: "rgba(0,0,0,0.4)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 100 }} data-testid="group-create-modal">
385
- <form onSubmit={handleCreateGroup} style={{ backgroundColor: "#ffffff", padding: "1.5rem", borderRadius: "8px", width: "360px", boxShadow: "0 10px 25px rgba(0,0,0,0.15)" }}>
386
- <h3 style={{ margin: "0 0 1rem 0", fontSize: "1.1rem" }}>{labels.groupsView.addGroup}</h3>
544
+ <div style={{ position: "fixed", inset: 0, backgroundColor: "rgba(0,0,0,0.4)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 100 }}>
545
+ <form
546
+ ref={createGroupRef}
547
+ onSubmit={handleCreateGroup}
548
+ role="dialog"
549
+ aria-modal="true"
550
+ aria-labelledby="create-group-title"
551
+ aria-describedby="create-group-error"
552
+ data-testid="group-create-modal"
553
+ style={{ backgroundColor: "#ffffff", padding: "1.5rem", borderRadius: "8px", width: "360px", boxShadow: "0 10px 25px rgba(0,0,0,0.15)" }}>
554
+ <h3 id="create-group-title" style={{ margin: "0 0 1rem 0", fontSize: "1.1rem" }}>
555
+ {labels.groupsView.addGroup}
556
+ </h3>
557
+ <ActionError result={actionResult} id="create-group-error" />
387
558
  <div style={{ marginBottom: "1rem" }}>
388
559
  <label htmlFor="create-group-key-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
389
560
  {labels.groupsView.groupKey}
@@ -427,11 +598,11 @@ export function GroupsView() {
427
598
  />
428
599
  </div>
429
600
  <div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
430
- <button type="button" onClick={() => setIsCreateOpen(false)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
431
- キャンセル
601
+ <button type="button" onClick={() => setModal(null)} data-testid="cancel-create-group" style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
602
+ {labels.common.cancel}
432
603
  </button>
433
- <button type="submit" data-testid="submit-create-group" style={{ padding: "6px 12px", borderRadius: "4px", backgroundColor: "#4338ca", color: "#ffffff", border: "none", cursor: "pointer" }}>
434
- 作成
604
+ <button type="submit" data-testid="submit-create-group" disabled={busy} aria-busy={busy} style={{ padding: "6px 12px", borderRadius: "4px", backgroundColor: "#4338ca", color: "#ffffff", border: "none", cursor: "pointer" }}>
605
+ {labels.common.create}
435
606
  </button>
436
607
  </div>
437
608
  </form>
@@ -440,9 +611,20 @@ export function GroupsView() {
440
611
 
441
612
  {/* Edit Group Modal */}
442
613
  {editingGroup && (
443
- <div style={{ position: "fixed", inset: 0, backgroundColor: "rgba(0,0,0,0.4)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 100 }} data-testid="group-edit-modal">
444
- <form onSubmit={handleUpdateGroup} style={{ backgroundColor: "#ffffff", padding: "1.5rem", borderRadius: "8px", width: "360px", boxShadow: "0 10px 25px rgba(0,0,0,0.15)" }}>
445
- <h3 style={{ margin: "0 0 1rem 0", fontSize: "1.1rem" }}>{labels.groupsView.editGroup}</h3>
614
+ <div style={{ position: "fixed", inset: 0, backgroundColor: "rgba(0,0,0,0.4)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 100 }}>
615
+ <form
616
+ ref={editGroupRef}
617
+ onSubmit={handleUpdateGroup}
618
+ role="dialog"
619
+ aria-modal="true"
620
+ aria-labelledby="edit-group-title"
621
+ aria-describedby="edit-group-error"
622
+ data-testid="group-edit-modal"
623
+ style={{ backgroundColor: "#ffffff", padding: "1.5rem", borderRadius: "8px", width: "360px", boxShadow: "0 10px 25px rgba(0,0,0,0.15)" }}>
624
+ <h3 id="edit-group-title" style={{ margin: "0 0 1rem 0", fontSize: "1.1rem" }}>
625
+ {labels.groupsView.editGroup}
626
+ </h3>
627
+ <ActionError result={actionResult} id="edit-group-error" />
446
628
  <div style={{ marginBottom: "1rem" }}>
447
629
  <label htmlFor="edit-group-name-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
448
630
  {labels.groupsView.name}
@@ -451,7 +633,7 @@ export function GroupsView() {
451
633
  id="edit-group-name-input"
452
634
  type="text"
453
635
  value={editingGroup.name}
454
- onChange={(e) => setEditingGroup({ ...editingGroup, name: e.target.value })}
636
+ onChange={(e) => setModal({ kind: "edit", group: { ...editingGroup, name: e.target.value } })}
455
637
  required
456
638
  data-testid="edit-group-name"
457
639
  style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box" }}
@@ -464,17 +646,17 @@ export function GroupsView() {
464
646
  <textarea
465
647
  id="edit-group-desc-input"
466
648
  value={editingGroup.description ?? ""}
467
- onChange={(e) => setEditingGroup({ ...editingGroup, description: e.target.value })}
649
+ onChange={(e) => setModal({ kind: "edit", group: { ...editingGroup, description: e.target.value } })}
468
650
  data-testid="edit-group-description"
469
651
  style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box", height: "60px" }}
470
652
  />
471
653
  </div>
472
654
  <div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
473
- <button type="button" onClick={() => setEditingGroup(null)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
474
- {labels.groupsView.cancel}
655
+ <button type="button" onClick={() => setModal(null)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
656
+ {labels.common.cancel}
475
657
  </button>
476
- <button type="submit" data-testid="submit-edit-group" style={{ padding: "6px 12px", borderRadius: "4px", backgroundColor: "#4338ca", color: "#ffffff", border: "none", cursor: "pointer" }}>
477
- {labels.groupsView.save}
658
+ <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" }}>
659
+ {labels.common.save}
478
660
  </button>
479
661
  </div>
480
662
  </form>
@@ -491,24 +673,28 @@ export function GroupsView() {
491
673
  export function GroupMembersView() {
492
674
  const data = useLoaderData() as UserAdminLoaderData
493
675
  const fetcher = useFetcher()
676
+ const { submit: submitThen, result: actionResult } = useSubmitThen(fetcher)
677
+ const busy = fetcher.state !== "idle"
494
678
  const labels = data.labels ?? defaultUserAdminLabels
495
679
 
496
680
  const [selectedGroupId, setSelectedGroupId] = useState<number>(data.groups[0]?.id ?? 0)
497
681
 
498
- const currentGroup = data.groups.find((g) => g.id === selectedGroupId)
682
+ // 再検証で対象が消えることがある。消えた ID を握り続けると空の面になる
683
+ const currentGroup = data.groups.find((g) => g.id === selectedGroupId) ?? data.groups[0]
499
684
  const currentMemberUserIds = new Set(data.groupMembers.filter((m) => m.groupId === selectedGroupId).map((m) => m.userId))
500
685
 
501
686
  const handleToggleMember = (userId: number, isMember: boolean) => {
502
687
  if (isMember) {
503
- fetcher.submit({ intent: "removeGroupMember", groupId: String(selectedGroupId), userId: String(userId) }, { method: "post" })
688
+ submitThen({ intent: "removeGroupMember", groupId: String(selectedGroupId), userId: String(userId) })
504
689
  } else {
505
- fetcher.submit({ intent: "addGroupMember", groupId: String(selectedGroupId), userId: String(userId) }, { method: "post" })
690
+ submitThen({ intent: "addGroupMember", groupId: String(selectedGroupId), userId: String(userId) })
506
691
  }
507
692
  }
508
693
 
509
694
  return (
510
695
  <section data-testid="group-members-view-section" style={{ display: "grid", gridTemplateColumns: "240px 1fr", gap: "1.2rem" }}>
511
- <ActionError fetcher={fetcher} />
696
+ <h2 style={{ ...heading, gridColumn: "1 / -1" }}>{labels.groupMembersView.title}</h2>
697
+ <ActionError result={actionResult} />
512
698
  <div style={{ backgroundColor: "#ffffff", padding: "1rem", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
513
699
  <h4 style={{ margin: "0 0 0.8rem 0", fontSize: "0.9rem", color: "#475569" }}>{labels.groupMembersView.selectGroup}</h4>
514
700
  <div style={{ display: "flex", flexDirection: "column", gap: "0.4rem" }}>
@@ -532,6 +718,11 @@ export function GroupMembersView() {
532
718
  fontSize: "0.85rem",
533
719
  }}>
534
720
  {g.name}
721
+ {g.readOnly ? (
722
+ <span data-testid={`group-upstream-marker-${g.id}`} style={{ marginLeft: "6px", fontSize: "0.7rem", color: "#7c3aed" }}>
723
+ {labels.common.managedUpstream}
724
+ </span>
725
+ ) : null}
535
726
  </button>
536
727
  )
537
728
  })}
@@ -542,8 +733,15 @@ export function GroupMembersView() {
542
733
  {currentGroup ? (
543
734
  <>
544
735
  <div style={{ marginBottom: "1rem", borderBottom: "1px solid #e2e8f0", paddingBottom: "0.8rem" }}>
545
- <h3 style={{ margin: 0, fontSize: "1.1rem", color: "#0f172a" }}>{currentGroup.name} の所属メンバー</h3>
546
- <span style={{ fontSize: "0.8rem", color: "#64748b" }}>グループキー: {currentGroup.groupKey}</span>
736
+ <h3 style={{ margin: 0, fontSize: "1.1rem", color: "#0f172a" }}>{fill(labels.groupMembersView.membersOfTemplate, { group: currentGroup.name })}</h3>
737
+ <span style={{ fontSize: "0.8rem", color: "#64748b" }}>
738
+ {labels.groupMembersView.groupKeyLabel}: {currentGroup.groupKey}
739
+ </span>
740
+ {currentGroup.readOnly ? (
741
+ <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" }}>
742
+ {labels.groupMembersView.upstreamNotice}
743
+ </div>
744
+ ) : null}
547
745
  </div>
548
746
  <div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
549
747
  {data.users.map((u) => {
@@ -565,29 +763,40 @@ export function GroupMembersView() {
565
763
  <span style={{ fontWeight: 600, fontSize: "0.88rem", color: "#0f172a", marginRight: "8px" }}>{u.displayName}</span>
566
764
  <span style={{ fontSize: "0.78rem", color: "#64748b" }}>({u.email})</span>
567
765
  </div>
568
- <button
569
- type="button"
570
- onClick={() => handleToggleMember(u.id, isMember)}
571
- data-testid={`toggle-member-btn-${u.id}`}
572
- style={{
573
- padding: "4px 12px",
574
- borderRadius: "4px",
575
- border: isMember ? "1px solid #b91c1c" : "1px solid #0284c7",
576
- backgroundColor: isMember ? "#ffffff" : "#0284c7",
577
- color: isMember ? "#b91c1c" : "#ffffff",
578
- fontSize: "0.78rem",
579
- fontWeight: 600,
580
- cursor: "pointer",
581
- }}>
582
- {isMember ? labels.groupMembersView.actionRemove : labels.groupMembersView.actionAdd}
583
- </button>
766
+ {currentGroup.readOnly ? (
767
+ isMember ? (
768
+ <span data-testid={`member-readonly-${u.id}`} style={{ fontSize: "0.75rem", color: "#475569" }}>
769
+ {labels.common.managedUpstream}
770
+ </span>
771
+ ) : null
772
+ ) : (
773
+ <button
774
+ type="button"
775
+ onClick={() => handleToggleMember(u.id, isMember)}
776
+ disabled={busy}
777
+ aria-busy={busy}
778
+ aria-label={fill(isMember ? labels.groupMembersView.actionRemoveAria : labels.groupMembersView.actionAddAria, { user: u.displayName })}
779
+ data-testid={`toggle-member-btn-${u.id}`}
780
+ style={{
781
+ padding: "4px 12px",
782
+ borderRadius: "4px",
783
+ border: isMember ? "1px solid #b91c1c" : "1px solid #0284c7",
784
+ backgroundColor: isMember ? "#ffffff" : "#0284c7",
785
+ color: isMember ? "#b91c1c" : "#ffffff",
786
+ fontSize: "0.78rem",
787
+ fontWeight: 600,
788
+ cursor: "pointer",
789
+ }}>
790
+ {isMember ? labels.groupMembersView.actionRemove : labels.groupMembersView.actionAdd}
791
+ </button>
792
+ )}
584
793
  </div>
585
794
  )
586
795
  })}
587
796
  </div>
588
797
  </>
589
798
  ) : (
590
- <div style={{ color: "#94a3b8", textAlign: "center", padding: "2rem" }}>{labels.groupMembersView.selectPrompt}</div>
799
+ <div style={{ color: "#475569", textAlign: "center", padding: "2rem" }}>{labels.groupMembersView.selectPrompt}</div>
591
800
  )}
592
801
  </div>
593
802
  </section>
@@ -617,6 +826,8 @@ export function AllowlistView() {
617
826
  const fetcher = useFetcher()
618
827
  const labels = data.labels ?? defaultUserAdminLabels
619
828
 
829
+ const { submit: submitThen, result: actionResult } = useSubmitThen(fetcher)
830
+ const busy = fetcher.state !== "idle"
620
831
  const [pattern, setPattern] = useState("")
621
832
  const [type, setType] = useState<"email" | "domain">("email")
622
833
  const [description, setDescription] = useState("")
@@ -624,23 +835,26 @@ export function AllowlistView() {
624
835
  const handleAddEntry = (e: React.FormEvent) => {
625
836
  e.preventDefault()
626
837
  if (!pattern) return
627
- fetcher.submit({ intent: "addAllowlistEntry", pattern, type, description }, { method: "post" })
628
- setPattern("")
629
- setDescription("")
838
+ const sent = { pattern, description }
839
+ submitThen({ intent: "addAllowlistEntry", pattern: sent.pattern, type, description: sent.description }, () => {
840
+ setPattern((current) => (current === sent.pattern ? "" : current))
841
+ setDescription((current) => (current === sent.description ? "" : current))
842
+ })
630
843
  }
631
844
 
632
845
  const handleRemoveEntry = (id: number) => {
633
- fetcher.submit({ intent: "removeAllowlistEntry", entryId: String(id) }, { method: "post" })
846
+ submitThen({ intent: "removeAllowlistEntry", entryId: String(id) })
634
847
  }
635
848
 
636
849
  /** グループ許可は別の表に住むため、別の intent で消す。 */
637
850
  const handleRemoveGroupEntry = (id: number) => {
638
- fetcher.submit({ intent: "removeAllowlistGroup", entryId: String(id) }, { method: "post" })
851
+ submitThen({ intent: "removeAllowlistGroup", entryId: String(id) })
639
852
  }
640
853
 
641
854
  return (
642
855
  <section data-testid="allowlist-view-section" style={{ display: "flex", flexDirection: "column", gap: "1.2rem" }}>
643
- <ActionError fetcher={fetcher} />
856
+ <h2 style={heading}>{labels.allowlistView.title}</h2>
857
+ <ActionError result={actionResult} />
644
858
  <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" }}>
645
859
  <div style={{ flex: 2, minWidth: "220px" }}>
646
860
  <label htmlFor="allowlist-pattern-input-field" style={{ display: "block", fontSize: "0.78rem", color: "#475569", marginBottom: "4px" }}>
@@ -683,7 +897,7 @@ export function AllowlistView() {
683
897
  style={{ width: "100%", padding: "6px 10px", borderRadius: "6px", border: "1px solid #cbd5e1", fontSize: "0.85rem", boxSizing: "border-box" }}
684
898
  />
685
899
  </div>
686
- <button type="submit" data-testid="submit-allowlist-add" style={{ padding: "7px 16px", borderRadius: "6px", backgroundColor: "#047857", color: "#ffffff", border: "none", fontSize: "0.85rem", fontWeight: 600, cursor: "pointer" }}>
900
+ <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" }}>
687
901
  {labels.allowlistView.addEntry}
688
902
  </button>
689
903
  </form>
@@ -692,22 +906,22 @@ export function AllowlistView() {
692
906
  <table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.85rem", textAlign: "left" }}>
693
907
  <thead>
694
908
  <tr style={{ backgroundColor: "#f8fafc", borderBottom: "1px solid #e2e8f0", color: "#475569" }}>
695
- <th style={{ padding: "10px 14px" }}>ID</th>
909
+ <th style={{ padding: "10px 14px" }}>{labels.common.id}</th>
696
910
  <th style={{ padding: "10px 14px" }}>{labels.allowlistView.target}</th>
697
911
  <th style={{ padding: "10px 14px" }}>{labels.allowlistView.type}</th>
698
912
  <th style={{ padding: "10px 14px" }}>{labels.allowlistView.description}</th>
699
- <th style={{ padding: "10px 14px", textAlign: "right" }}>{labels.allowlistView.actions}</th>
913
+ <th style={{ padding: "10px 14px", textAlign: "right" }}>{labels.common.actions}</th>
700
914
  </tr>
701
915
  </thead>
702
916
  <tbody>
703
917
  {data.allowlist.map((item) => (
704
- <tr key={`${item.type}-${item.id}`} style={{ borderBottom: "1px solid #f1f5f9" }} data-testid={`allowlist-row-${item.id}`} data-allowlist-type={item.type}>
705
- <td style={{ padding: "10px 14px", color: "#64748b" }}>#{item.id}</td>
918
+ <tr key={`${item.type}-${item.id}`} style={{ borderBottom: "1px solid #f1f5f9" }} data-testid={`allowlist-row-${item.type}-${item.id}`} data-allowlist-type={item.type}>
919
+ <td style={{ padding: "10px 14px", color: "#475569" }}>{fill(labels.common.idTemplate, { id: item.id })}</td>
706
920
  <td style={{ padding: "10px 14px", fontWeight: 600, color: "#0f172a" }}>
707
921
  {/* パターン許可とグループ許可では正準値が違う。グループはアドレスではなく
708
922
  グループそのものが対象なので、名前とキーを見せる */}
709
923
  {item.type === "group" ? (
710
- <span data-testid={`allowlist-group-${item.id}`}>
924
+ <span data-testid={`allowlist-group-${item.type}-${item.id}`}>
711
925
  {item.groupName}
712
926
  <span style={{ marginLeft: "6px", fontFamily: "monospace", fontSize: "0.78rem", color: "#64748b" }}>{item.groupKey}</span>
713
927
  </span>
@@ -719,10 +933,10 @@ export function AllowlistView() {
719
933
  <span style={{ fontSize: "0.75rem", padding: "2px 8px", borderRadius: "10px", ...allowlistBadgeStyle(item.type), fontWeight: 600 }}>{allowlistTypeLabel(item.type, labels)}</span>
720
934
  </td>
721
935
  <td style={{ padding: "10px 14px", color: "#475569" }}>
722
- {item.description || "-"}
936
+ {item.description || labels.common.none}
723
937
  {/* 配備全体が所有する行は、どのテナントの管理者でも編集できない */}
724
938
  {item.owner.kind === "deployment" && (
725
- <span data-testid={`allowlist-owner-${item.id}`} style={{ marginLeft: "8px", fontSize: "0.72rem", color: "#94a3b8" }}>
939
+ <span data-testid={`allowlist-owner-${item.type}-${item.id}`} style={{ marginLeft: "8px", fontSize: "0.72rem", color: "#475569" }}>
726
940
  {labels.allowlistView.deploymentOwner}
727
941
  </span>
728
942
  )}
@@ -731,16 +945,24 @@ export function AllowlistView() {
731
945
  {/* 変更不能な供給元 (環境変数など) は削除ボタンを出さない。無効化ではなく
732
946
  非描画にするのは、押せないボタンが押せる操作と見分けられないため */}
733
947
  {item.readOnly ? (
734
- <span data-testid={`readonly-allowlist-${item.id}`} style={{ fontSize: "0.75rem", color: "#94a3b8" }}>
735
- {labels.allowlistView.readOnly}
948
+ <span data-testid={`readonly-allowlist-${item.type}-${item.id}`} style={{ fontSize: "0.75rem", color: "#475569" }}>
949
+ {labels.common.readOnly}
736
950
  </span>
737
951
  ) : (
738
952
  <button
739
953
  type="button"
740
- onClick={() => (item.type === "group" ? handleRemoveGroupEntry(item.id) : handleRemoveEntry(item.id))}
741
- data-testid={`remove-allowlist-btn-${item.id}`}
954
+ onClick={() => {
955
+ // これでしかサインインできない人はログインできなくなる。取り消せない
956
+ if (!window.confirm(labels.allowlistView.confirmDelete)) return
957
+ if (item.type === "group") handleRemoveGroupEntry(item.id)
958
+ else handleRemoveEntry(item.id)
959
+ }}
960
+ disabled={busy}
961
+ aria-busy={busy}
962
+ aria-label={fill(labels.allowlistView.deleteAria, { target: item.type === "group" ? item.groupName : item.pattern })}
963
+ data-testid={`remove-allowlist-btn-${item.type}-${item.id}`}
742
964
  style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #fee2e2", backgroundColor: "#fff5f5", color: "#b91c1c", fontSize: "0.78rem", cursor: "pointer" }}>
743
- {labels.allowlistView.delete}
965
+ {labels.common.delete}
744
966
  </button>
745
967
  )}
746
968
  </td>
@@ -748,7 +970,7 @@ export function AllowlistView() {
748
970
  ))}
749
971
  {data.allowlist.length === 0 && (
750
972
  <tr>
751
- <td colSpan={5} style={{ padding: "2rem", textAlign: "center", color: "#94a3b8" }}>
973
+ <td colSpan={5} style={{ padding: "2rem", textAlign: "center", color: "#475569" }}>
752
974
  {labels.allowlistView.empty}
753
975
  </td>
754
976
  </tr>
@@ -760,11 +982,258 @@ export function AllowlistView() {
760
982
  )
761
983
  }
762
984
 
985
+ /**
986
+ * View for the upstream directory: what is linked, how fresh it is, and the controls that change it.
987
+ * 上流ディレクトリのビュー。何が連携され、どれだけ新しく、それを変える操作を提供する。
988
+ *
989
+ * このタブが無いと、連携の作成・解除・一時停止・即時同期・グループ単位のサインイン許可という
990
+ * 5 つの操作にどこからも到達できない。実装されているのに押せる場所が無い状態は、実装が無いのと
991
+ * 変わらないうえ、「許可を消せるが作れない」という片肺の運用を強いる。
992
+ */
993
+ export function DirectoryView() {
994
+ const data = useLoaderData() as UserAdminLoaderData
995
+ const fetcher = useFetcher()
996
+ const { submit: submitThen, result: actionResult } = useSubmitThen(fetcher)
997
+ const labels = data.labels ?? defaultUserAdminLabels
998
+ const L = labels.directoryView
999
+
1000
+ const linkable = data.groups.filter((g) => !g.externalLink)
1001
+ const [groupId, setGroupId] = useState("")
1002
+ const [externalKey, setExternalKey] = useState("")
1003
+ const [membershipMode, setMembershipMode] = useState<AuthDirectoryMembershipMode>("direct")
1004
+
1005
+ const linked = data.groups.filter((g) => g.externalLink)
1006
+ const allowedGroupIds = new Set(data.allowlist.filter((e) => e.type === "group").map((e) => e.groupId))
1007
+ const allowlistEntryOf = (id: number) => data.allowlist.find((e) => e.type === "group" && e.groupId === id)
1008
+ const busy = fetcher.state !== "idle"
1009
+ // 「要求しました」を出す対象。押しても何も変わらないと、何度も押される
1010
+ const [queuedGroupId, setQueuedGroupId] = useState<number | null>(null)
1011
+ // 供給元が判らないまま欄を出すと、送信は必ずサーバー側の内部文言で失敗する
1012
+ const canLink = data.directoryStatus.isEnabled && data.directoryStatus.provider !== ""
1013
+
1014
+ /** 連携を作る。上流キーは運用者が知っている値 (グループアドレス) で受け取る。 */
1015
+ const handleLink = (e: React.FormEvent) => {
1016
+ e.preventDefault()
1017
+ if (!(groupId && externalKey)) return
1018
+ // 供給元は配備の設定から来る。ここに特定の値を書くと、別の上流を使う配備で作った
1019
+ // リンクが同期側で恒久的に拒否される
1020
+ const sentKey = externalKey
1021
+ submitThen({ intent: "linkDirectoryGroup", groupId, provider: data.directoryStatus.provider, externalKey: sentKey, membershipMode }, () => {
1022
+ setExternalKey((current) => (current === sentKey ? "" : current))
1023
+ // 連携済みになった行は候補から消える。選択を残すと、表示は空欄なのに前の
1024
+ // グループへ送信される
1025
+ setGroupId("")
1026
+ })
1027
+ }
1028
+
1029
+ return (
1030
+ <section data-testid="directory-view-section" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
1031
+ <h2 style={heading}>{L.title}</h2>
1032
+ <ActionError result={actionResult} />
1033
+
1034
+ {!canLink && (
1035
+ <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" }}>
1036
+ {L.notWiredNotice}
1037
+ </p>
1038
+ )}
1039
+
1040
+ {canLink && linkable.length === 0 && (
1041
+ <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" }}>
1042
+ {L.nothingToLink}
1043
+ </p>
1044
+ )}
1045
+
1046
+ {canLink && linkable.length > 0 && (
1047
+ <form onSubmit={handleLink} data-testid="directory-link-form" style={{ display: "flex", gap: "0.75rem", flexWrap: "wrap", alignItems: "flex-end", backgroundColor: "#ffffff", padding: "1rem", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
1048
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
1049
+ <label htmlFor="directory-group-select-input" style={fieldLabel}>
1050
+ {L.selectGroup}
1051
+ </label>
1052
+ <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" }}>
1053
+ <option value="">{L.selectGroup}</option>
1054
+ {linkable.map((g) => (
1055
+ <option key={g.id} value={String(g.id)}>
1056
+ {fill(L.groupOptionTemplate, { name: g.name, key: g.groupKey })}
1057
+ </option>
1058
+ ))}
1059
+ </select>
1060
+ </div>
1061
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.25rem", flex: 1, minWidth: "220px" }}>
1062
+ <label htmlFor="directory-external-key-input" style={fieldLabel}>
1063
+ {L.externalKey}
1064
+ </label>
1065
+ <input
1066
+ id="directory-external-key-input"
1067
+ type="email"
1068
+ required
1069
+ data-testid="directory-external-key"
1070
+ placeholder={L.externalKeyPlaceholder}
1071
+ value={externalKey}
1072
+ onChange={(e) => setExternalKey(e.target.value)}
1073
+ style={{ padding: "0.5rem", borderRadius: "6px", border: "1px solid #cbd5e1" }}
1074
+ />
1075
+ </div>
1076
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
1077
+ <label htmlFor="directory-membership-mode-input" style={fieldLabel}>
1078
+ {L.membershipMode}
1079
+ </label>
1080
+ <select
1081
+ id="directory-membership-mode-input"
1082
+ data-testid="directory-membership-mode"
1083
+ value={membershipMode}
1084
+ onChange={(e) => setMembershipMode(e.target.value as AuthDirectoryMembershipMode)}
1085
+ style={{ padding: "0.5rem", borderRadius: "6px", border: "1px solid #cbd5e1" }}>
1086
+ <option value="direct">{L.directMode}</option>
1087
+ <option value="transitive">{L.transitiveMode}</option>
1088
+ </select>
1089
+ </div>
1090
+ <button
1091
+ type="submit"
1092
+ data-testid="directory-link-submit"
1093
+ disabled={!(groupId && externalKey) || busy}
1094
+ aria-busy={busy}
1095
+ style={{ padding: "0.5rem 1rem", borderRadius: "6px", border: "none", backgroundColor: groupId && externalKey && !busy ? "#2563eb" : "#94a3b8", color: "#fff", fontWeight: 600 }}>
1096
+ {L.link}
1097
+ </button>
1098
+ </form>
1099
+ )}
1100
+
1101
+ {linked.length === 0 ? (
1102
+ <div data-testid="directory-empty" style={{ padding: "2rem", textAlign: "center", color: "#64748b", backgroundColor: "#ffffff", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
1103
+ {L.empty}
1104
+ </div>
1105
+ ) : (
1106
+ <table data-testid="directory-table" style={{ width: "100%", borderCollapse: "collapse", backgroundColor: "#ffffff", borderRadius: "8px", overflow: "hidden", border: "1px solid #e2e8f0" }}>
1107
+ <thead>
1108
+ <tr style={{ backgroundColor: "#f8fafc", textAlign: "left" }}>
1109
+ <th style={cell}>{labels.groupsView.name}</th>
1110
+ <th style={cell}>{L.externalKey}</th>
1111
+ <th style={cell}>{L.membershipMode}</th>
1112
+ <th style={cell}>{L.lastSynced}</th>
1113
+ <th style={cell}>{labels.common.status}</th>
1114
+ <th style={cell}>{labels.common.actions}</th>
1115
+ </tr>
1116
+ </thead>
1117
+ <tbody>
1118
+ {linked.map((g) => {
1119
+ const link = g.externalLink as NonNullable<AuthGroup["externalLink"]>
1120
+ const allowed = allowedGroupIds.has(g.id)
1121
+ return (
1122
+ <tr key={g.id} data-testid={`directory-row-${g.id}`} data-sync-enabled={String(link.isSyncEnabled)} style={{ borderTop: "1px solid #e2e8f0" }}>
1123
+ <td style={cell}>
1124
+ {g.name}
1125
+ <div style={{ fontSize: "0.75rem", color: "#64748b" }}>{g.groupKey}</div>
1126
+ </td>
1127
+ <td style={cell} data-testid={`directory-external-${g.id}`}>
1128
+ {link.externalKey}
1129
+ </td>
1130
+ <td style={cell}>{link.membershipMode === "direct" ? L.directMode : L.transitiveMode}</td>
1131
+ <td style={cell} data-testid={`directory-synced-${g.id}`}>
1132
+ {link.syncedAt ? formatDateTime(link.syncedAt, labels) : L.neverSynced}
1133
+ {link.unresolvedCount > 0 ? (
1134
+ <div style={{ fontSize: "0.75rem", color: "#b45309" }} data-testid={`directory-unresolved-${g.id}`}>
1135
+ {fill(L.unresolvedTemplate, { count: link.unresolvedCount })}
1136
+ </div>
1137
+ ) : null}
1138
+ </td>
1139
+ <td style={cell}>
1140
+ <span
1141
+ data-testid={`directory-status-${g.id}`}
1142
+ style={{ fontSize: "0.75rem", padding: "2px 8px", borderRadius: "10px", backgroundColor: link.isSyncEnabled ? "#dcfce7" : "#fee2e2", color: link.isSyncEnabled ? "#166534" : "#b91c1c", fontWeight: 600 }}>
1143
+ {link.isSyncEnabled ? L.syncEnabled : L.syncPaused}
1144
+ </span>
1145
+ {link.syncError ? (
1146
+ <div style={{ fontSize: "0.75rem", color: "#b91c1c", marginTop: "0.25rem" }} data-testid={`directory-error-${g.id}`}>
1147
+ {link.syncError}
1148
+ </div>
1149
+ ) : null}
1150
+ </td>
1151
+ {/* td 自体を flex にすると表セルの役割が失われ、列と見出しの対応が切れる */}
1152
+ <td style={cell}>
1153
+ <div style={{ display: "flex", gap: "0.35rem", flexWrap: "wrap" }}>
1154
+ <button
1155
+ type="button"
1156
+ data-testid={`directory-sync-now-${g.id}`}
1157
+ disabled={busy}
1158
+ aria-busy={busy}
1159
+ aria-label={fill(queuedGroupId === g.id ? L.syncQueuedAria : L.syncNowAria, { group: g.name })}
1160
+ onClick={() => submitThen({ intent: "requestDirectorySync", groupId: String(g.id) }, () => setQueuedGroupId(g.id))}
1161
+ style={smallButton}>
1162
+ {queuedGroupId === g.id ? L.syncQueued : L.syncNow}
1163
+ </button>
1164
+ <button
1165
+ type="button"
1166
+ data-testid={`directory-toggle-sync-${g.id}`}
1167
+ disabled={busy}
1168
+ aria-busy={busy}
1169
+ aria-label={fill(link.isSyncEnabled ? L.pauseAria : L.resumeAria, { group: g.name })}
1170
+ onClick={() => {
1171
+ setQueuedGroupId(null)
1172
+ submitThen({ intent: "setDirectorySyncEnabled", groupId: String(g.id), isSyncEnabled: String(!link.isSyncEnabled) })
1173
+ }}
1174
+ style={smallButton}>
1175
+ {link.isSyncEnabled ? L.pause : L.resume}
1176
+ </button>
1177
+ <button
1178
+ type="button"
1179
+ data-testid={`directory-allow-${g.id}`}
1180
+ disabled={busy}
1181
+ aria-busy={busy}
1182
+ aria-label={fill(allowed ? L.disallowGroupAria : L.allowGroupAria, { group: g.name })}
1183
+ onClick={() => {
1184
+ const entry = allowlistEntryOf(g.id)
1185
+ if (allowed && entry) {
1186
+ // 取り消しはサインインを止める。追加は元に戻すだけなので確認しない
1187
+ if (!window.confirm(L.confirmDisallow)) return
1188
+ submitThen({ intent: "removeAllowlistGroup", entryId: String(entry.id) })
1189
+ } else {
1190
+ submitThen({ intent: "addAllowlistGroup", groupId: String(g.id) })
1191
+ }
1192
+ }}
1193
+ style={smallButton}>
1194
+ {allowed ? L.disallowGroup : L.allowGroup}
1195
+ </button>
1196
+ <button
1197
+ type="button"
1198
+ data-testid={`directory-unlink-${g.id}`}
1199
+ disabled={busy}
1200
+ aria-busy={busy}
1201
+ aria-label={fill(L.unlinkAria, { group: g.name })}
1202
+ onClick={() => {
1203
+ // 台帳とグループ単位の許可が失われる。取り消せないので必ず確認する
1204
+ if (!window.confirm(L.confirmUnlink)) return
1205
+ submitThen({ intent: "unlinkDirectoryGroup", groupId: String(g.id) })
1206
+ }}
1207
+ style={{ ...smallButton, color: "#b91c1c", borderColor: "#fecaca" }}>
1208
+ {L.unlink}
1209
+ </button>
1210
+ </div>
1211
+ </td>
1212
+ </tr>
1213
+ )
1214
+ })}
1215
+ </tbody>
1216
+ </table>
1217
+ )}
1218
+ </section>
1219
+ )
1220
+ }
1221
+
1222
+ /** フォーム項目の見出し。プレースホルダは入力すると消えるため名前にならない。 */
1223
+ const fieldLabel: React.CSSProperties = { fontSize: "0.78rem", color: "#475569", fontWeight: 600 }
1224
+ /** 各ビューの見出し。段階を飛ばさないよう h2 に固定する。 */
1225
+ const heading: React.CSSProperties = { margin: 0, fontSize: "1.05rem", fontWeight: 700, color: "#0f172a" }
1226
+ /** 表セルの共通スタイル。 */
1227
+ const cell: React.CSSProperties = { padding: "0.6rem 0.75rem", fontSize: "0.85rem", verticalAlign: "top" }
1228
+ /** 行内の小さな操作ボタン。 */
1229
+ const smallButton: React.CSSProperties = { padding: "0.25rem 0.6rem", fontSize: "0.75rem", borderRadius: "6px", border: "1px solid #cbd5e1", backgroundColor: "#fff", cursor: "pointer" }
1230
+
763
1231
  const SEGMENT_VIEWS: Record<string, () => React.ReactElement> = {
764
1232
  users: UsersView,
765
1233
  groups: GroupsView,
766
1234
  group_members: GroupMembersView,
767
1235
  allowlist: AllowlistView,
1236
+ directory: DirectoryView,
768
1237
  }
769
1238
 
770
1239
  export type AuthUserAdminAppViewProps = UserAdminShellProps
@@ -776,7 +1245,8 @@ export type AuthUserAdminAppViewProps = UserAdminShellProps
776
1245
  export function AuthUserAdminAppView({ renderHeader }: AuthUserAdminAppViewProps = {}) {
777
1246
  const data = useLoaderData() as UserAdminLoaderData | undefined
778
1247
  const segment = data?.segment ?? "users"
779
- const View = SEGMENT_VIEWS[segment] ?? UsersView
1248
+ // 素のオブジェクト参照だと `segment="constructor"` が真値を返し、画面が描けなくなる
1249
+ const View = Object.hasOwn(SEGMENT_VIEWS, segment) ? SEGMENT_VIEWS[segment] : UsersView
780
1250
 
781
1251
  return (
782
1252
  <UserAdminShell renderHeader={renderHeader}>