@aiquants/auth-react-router 0.11.0 → 0.12.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,19 +27,107 @@ export type UserAdminLoaderData = {
26
27
  * ロックアウト理由・重複パターン・入力不正などの文言は action が `{ok:false,error}` で返す。
27
28
  * 表示しなければ操作が黙って失敗したように見えるため、各ビューの先頭に必ず置く。
28
29
  */
29
- function ActionError({ fetcher }: { fetcher: { data?: unknown } }) {
30
+ function ActionError({ fetcher, id }: { fetcher: { data?: unknown }; id?: string }) {
30
31
  const result = fetcher.data 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
+ useEffect(() => {
56
+ if (fetcher.state !== "idle" || fetcher.data === undefined) return
57
+ const onSuccess = pending.current
58
+ if (!onSuccess) return
59
+ // 同じ応答で 2 度実行しないよう、消費した時点で捨てる。ここは **冗長な防御** である —
60
+ // すべての送信がこのフックを通る限り、後の送信が必ず `pending` を上書きするため。
61
+ // 素の `fetcher.submit` が 1 つでも混ざった瞬間に効き始める
62
+ pending.current = null
63
+ if ((fetcher.data as { ok?: boolean }).ok === true) onSuccess()
64
+ }, [fetcher.state, fetcher.data])
65
+
66
+ return (payload: Record<string, string>, onSuccess?: () => void) => {
67
+ pending.current = onSuccess ?? null
68
+ fetcher.submit(payload, { method: "post" })
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Gives a dialog the behavior its ARIA attributes promise: focus, trap, Escape, restore.
74
+ * ダイアログに、ARIA 属性が約束している挙動 (初期焦点・封じ込め・Escape・復帰) を与えるフック。
75
+ *
76
+ * 役割属性だけを付けても、焦点は背後に残り、Tab は外へ出て行き、Escape は効かない。
77
+ * キーボード利用者は「閉じる」に到達できず、支援技術は背後を読み続ける。
78
+ *
79
+ * @param isOpen Whether the dialog is mounted and visible. ダイアログが開いているかどうか。
80
+ * @param onClose Invoked when Escape is pressed. Escape が押されたときに呼ばれる処理。
81
+ * @returns Ref to attach to the dialog panel. ダイアログ本体へ付ける ref。
82
+ */
83
+ function useModalBehavior(isOpen: boolean, onClose: () => void) {
84
+ const ref = useRef<HTMLFormElement>(null)
85
+ // 再描画のたびに実行されると入力中に焦点が先頭へ飛ぶため、依存は開閉だけにする
86
+ const closeRef = useRef(onClose)
87
+ closeRef.current = onClose
88
+
89
+ useEffect(() => {
90
+ if (!isOpen) return
91
+ const panel = ref.current
92
+ const opener = document.activeElement as HTMLElement | null
93
+ 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"])') ?? [])
94
+ focusables()[0]?.focus()
95
+
96
+ const onKeyDown = (event: KeyboardEvent) => {
97
+ if (event.key === "Escape") {
98
+ event.preventDefault()
99
+ closeRef.current()
100
+ return
101
+ }
102
+ if (event.key !== "Tab") return
103
+ const items = focusables()
104
+ if (items.length === 0) return
105
+ const first = items[0]
106
+ const last = items[items.length - 1]
107
+ const active = document.activeElement
108
+ const outside = !panel?.contains(active)
109
+ if (event.shiftKey && (active === first || outside)) {
110
+ event.preventDefault()
111
+ last.focus()
112
+ } else if (!event.shiftKey && (active === last || outside)) {
113
+ event.preventDefault()
114
+ first.focus()
115
+ }
116
+ }
117
+ document.addEventListener("keydown", onKeyDown)
118
+ return () => {
119
+ document.removeEventListener("keydown", onKeyDown)
120
+ opener?.focus?.()
121
+ }
122
+ }, [isOpen])
123
+
124
+ return ref
125
+ }
126
+
39
127
  export function UsersView() {
40
128
  const data = useLoaderData() as UserAdminLoaderData
41
129
  const fetcher = useFetcher()
130
+ const submitThen = useSubmitThen(fetcher)
42
131
  const labels = data.labels ?? defaultUserAdminLabels
43
132
 
44
133
  const [searchQuery, setSearchQuery] = useState("")
@@ -47,35 +136,43 @@ export function UsersView() {
47
136
 
48
137
  const [newDisplayName, setNewDisplayName] = useState("")
49
138
  const [newEmail, setNewEmail] = useState("")
139
+ const createUserRef = useModalBehavior(isCreateOpen, () => setIsCreateOpen(false))
140
+ const editUserRef = useModalBehavior(editingUser !== null, () => setEditingUser(null))
50
141
 
142
+ const busy = fetcher.state !== "idle"
51
143
  const filteredUsers = data.users.filter((u) => u.displayName.toLowerCase().includes(searchQuery.toLowerCase()) || u.email.toLowerCase().includes(searchQuery.toLowerCase()))
52
144
 
53
145
  const handleToggleActive = (user: AuthUser) => {
54
- fetcher.submit({ intent: "toggleUserActive", userId: String(user.id), isActive: String(!user.isActive) }, { method: "post" })
146
+ // 無効化はサインインを止める。有効化は元に戻すだけなので確認しない
147
+ if (user.isActive && !window.confirm(labels.usersView.confirmDeactivate)) return
148
+ submitThen({ intent: "toggleUserActive", userId: String(user.id), isActive: String(!user.isActive) })
55
149
  }
56
150
 
57
151
  const handleCreateUser = (e: React.FormEvent) => {
58
152
  e.preventDefault()
59
153
  if (!(newDisplayName && newEmail)) return
60
- fetcher.submit({ intent: "createUser", displayName: newDisplayName, email: newEmail }, { method: "post" })
61
- setNewDisplayName("")
62
- setNewEmail("")
63
- setIsCreateOpen(false)
154
+ submitThen({ intent: "createUser", displayName: newDisplayName, email: newEmail }, () => {
155
+ setNewDisplayName("")
156
+ setNewEmail("")
157
+ setIsCreateOpen(false)
158
+ })
64
159
  }
65
160
 
66
161
  const handleUpdateUser = (e: React.FormEvent) => {
67
162
  e.preventDefault()
68
163
  if (!editingUser) return
69
- fetcher.submit({ intent: "updateUser", userId: String(editingUser.id), displayName: editingUser.displayName, email: editingUser.email }, { method: "post" })
70
- setEditingUser(null)
164
+ submitThen({ intent: "updateUser", userId: String(editingUser.id), displayName: editingUser.displayName, email: editingUser.email }, () => setEditingUser(null))
71
165
  }
72
166
 
73
167
  return (
74
168
  <section data-testid="users-view-section" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
75
- <ActionError fetcher={fetcher} />
169
+ <h2 style={heading}>{labels.usersView.title}</h2>
170
+ {/* モーダルが開いている間はモーダル内に描く。両方描くと同じ hook が 2 つになる */}
171
+ {!(isCreateOpen || editingUser) && <ActionError fetcher={fetcher} />}
76
172
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", backgroundColor: "#ffffff", padding: "1rem", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
77
173
  <input
78
174
  type="text"
175
+ aria-label={labels.usersView.searchLabel}
79
176
  placeholder={labels.usersView.searchPlaceholder}
80
177
  value={searchQuery}
81
178
  onChange={(e) => setSearchQuery(e.target.value)}
@@ -91,17 +188,17 @@ export function UsersView() {
91
188
  <table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.85rem", textAlign: "left" }}>
92
189
  <thead>
93
190
  <tr style={{ backgroundColor: "#f8fafc", borderBottom: "1px solid #e2e8f0", color: "#475569" }}>
94
- <th style={{ padding: "10px 14px" }}>{labels.usersView.id}</th>
191
+ <th style={{ padding: "10px 14px" }}>{labels.common.id}</th>
95
192
  <th style={{ padding: "10px 14px" }}>{labels.usersView.displayName}</th>
96
193
  <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>
194
+ <th style={{ padding: "10px 14px" }}>{labels.common.status}</th>
195
+ <th style={{ padding: "10px 14px", textAlign: "right" }}>{labels.common.actions}</th>
99
196
  </tr>
100
197
  </thead>
101
198
  <tbody>
102
199
  {filteredUsers.map((u) => (
103
200
  <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>
201
+ <td style={{ padding: "10px 14px", color: "#475569" }}>{fill(labels.common.idTemplate, { id: u.id })}</td>
105
202
  <td style={{ padding: "10px 14px", fontWeight: 600, color: "#0f172a" }}>{u.displayName}</td>
106
203
  <td style={{ padding: "10px 14px", color: "#475569" }}>{u.email}</td>
107
204
  <td style={{ padding: "10px 14px" }}>
@@ -113,15 +210,19 @@ export function UsersView() {
113
210
  <button
114
211
  type="button"
115
212
  onClick={() => setEditingUser(u)}
213
+ aria-label={fill(labels.usersView.editUserAria, { user: u.displayName })}
116
214
  data-testid={`edit-user-btn-${u.id}`}
117
215
  style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", fontSize: "0.78rem", cursor: "pointer", marginRight: "6px" }}>
118
- {labels.usersView.edit}
216
+ {labels.common.edit}
119
217
  </button>
120
218
  <button
121
219
  type="button"
122
220
  onClick={() => handleToggleActive(u)}
221
+ disabled={busy}
222
+ aria-busy={busy}
223
+ aria-label={fill(u.isActive ? labels.usersView.deactivateAria : labels.usersView.activateAria, { user: u.displayName })}
123
224
  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" }}>
225
+ 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
226
  {u.isActive ? labels.usersView.deactivate : labels.usersView.activate}
126
227
  </button>
127
228
  </td>
@@ -129,7 +230,7 @@ export function UsersView() {
129
230
  ))}
130
231
  {filteredUsers.length === 0 && (
131
232
  <tr>
132
- <td colSpan={5} style={{ padding: "2rem", textAlign: "center", color: "#94a3b8" }}>
233
+ <td colSpan={5} style={{ padding: "2rem", textAlign: "center", color: "#475569" }}>
133
234
  {labels.usersView.empty}
134
235
  </td>
135
236
  </tr>
@@ -140,9 +241,20 @@ export function UsersView() {
140
241
 
141
242
  {/* Create Modal */}
142
243
  {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>
244
+ <div style={{ position: "fixed", inset: 0, backgroundColor: "rgba(0,0,0,0.4)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 100 }}>
245
+ <form
246
+ ref={createUserRef}
247
+ onSubmit={handleCreateUser}
248
+ role="dialog"
249
+ aria-modal="true"
250
+ aria-labelledby="create-user-title"
251
+ aria-describedby="create-user-error"
252
+ data-testid="user-create-modal"
253
+ style={{ backgroundColor: "#ffffff", padding: "1.5rem", borderRadius: "8px", width: "360px", boxShadow: "0 10px 25px rgba(0,0,0,0.15)" }}>
254
+ <h3 id="create-user-title" style={{ margin: "0 0 1rem 0", fontSize: "1.1rem" }}>
255
+ {labels.usersView.addUser}
256
+ </h3>
257
+ <ActionError fetcher={fetcher} id="create-user-error" />
146
258
  <div style={{ marginBottom: "1rem" }}>
147
259
  <label htmlFor="create-user-display-name-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
148
260
  {labels.usersView.displayName}
@@ -175,7 +287,7 @@ export function UsersView() {
175
287
  <button type="button" onClick={() => setIsCreateOpen(false)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
176
288
  キャンセル
177
289
  </button>
178
- <button type="submit" data-testid="submit-create-user" style={{ padding: "6px 12px", borderRadius: "4px", backgroundColor: "#0284c7", color: "#ffffff", border: "none", cursor: "pointer" }}>
290
+ <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" }}>
179
291
  作成
180
292
  </button>
181
293
  </div>
@@ -185,9 +297,20 @@ export function UsersView() {
185
297
 
186
298
  {/* Edit Modal */}
187
299
  {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>
300
+ <div style={{ position: "fixed", inset: 0, backgroundColor: "rgba(0,0,0,0.4)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 100 }}>
301
+ <form
302
+ ref={editUserRef}
303
+ onSubmit={handleUpdateUser}
304
+ role="dialog"
305
+ aria-modal="true"
306
+ aria-labelledby="edit-user-title"
307
+ aria-describedby="edit-user-error"
308
+ data-testid="user-edit-modal"
309
+ style={{ backgroundColor: "#ffffff", padding: "1.5rem", borderRadius: "8px", width: "360px", boxShadow: "0 10px 25px rgba(0,0,0,0.15)" }}>
310
+ <h3 id="edit-user-title" style={{ margin: "0 0 1rem 0", fontSize: "1.1rem" }}>
311
+ {labels.usersView.editUser}
312
+ </h3>
313
+ <ActionError fetcher={fetcher} id="edit-user-error" />
191
314
  <div style={{ marginBottom: "1rem" }}>
192
315
  <label htmlFor="edit-user-display-name-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
193
316
  {labels.usersView.displayName}
@@ -218,10 +341,10 @@ export function UsersView() {
218
341
  </div>
219
342
  <div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
220
343
  <button type="button" onClick={() => setEditingUser(null)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
221
- {labels.usersView.cancel}
344
+ {labels.common.cancel}
222
345
  </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}
346
+ <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" }}>
347
+ {labels.common.save}
225
348
  </button>
226
349
  </div>
227
350
  </form>
@@ -235,77 +358,55 @@ export function UsersView() {
235
358
  * View for managing user groups (create, edit, delete).
236
359
  * ユーザーグループ管理ビュー (作成・編集・削除)。
237
360
  */
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
361
  export function GroupsView() {
266
362
  const data = useLoaderData() as UserAdminLoaderData
267
363
  const fetcher = useFetcher()
364
+ const submitThen = useSubmitThen(fetcher)
268
365
  const labels = data.labels ?? defaultUserAdminLabels
269
366
 
270
367
  const [searchQuery, setSearchQuery] = useState("")
271
368
  const [isCreateOpen, setIsCreateOpen] = useState(false)
272
369
  const [editingGroup, setEditingGroup] = useState<AuthGroup | null>(null)
273
370
 
371
+ const busy = fetcher.state !== "idle"
274
372
  const [newGroupKey, setNewGroupKey] = useState("")
275
373
  const [newName, setNewName] = useState("")
276
374
  const [newDescription, setNewDescription] = useState("")
375
+ const createGroupRef = useModalBehavior(isCreateOpen, () => setIsCreateOpen(false))
376
+ const editGroupRef = useModalBehavior(editingGroup !== null, () => setEditingGroup(null))
277
377
 
278
378
  const filteredGroups = data.groups.filter((g) => g.name.toLowerCase().includes(searchQuery.toLowerCase()) || g.groupKey.toLowerCase().includes(searchQuery.toLowerCase()))
279
379
 
280
380
  const handleCreateGroup = (e: React.FormEvent) => {
281
381
  e.preventDefault()
282
382
  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)
383
+ submitThen({ intent: "createGroup", groupKey: newGroupKey, name: newName, description: newDescription }, () => {
384
+ setNewGroupKey("")
385
+ setNewName("")
386
+ setNewDescription("")
387
+ setIsCreateOpen(false)
388
+ })
288
389
  }
289
390
 
290
391
  const handleUpdateGroup = (e: React.FormEvent) => {
291
392
  e.preventDefault()
292
393
  if (!editingGroup) return
293
- fetcher.submit({ intent: "updateGroup", groupId: String(editingGroup.id), name: editingGroup.name, description: editingGroup.description ?? "" }, { method: "post" })
294
- setEditingGroup(null)
394
+ submitThen({ intent: "updateGroup", groupId: String(editingGroup.id), name: editingGroup.name, description: editingGroup.description ?? "" }, () => setEditingGroup(null))
295
395
  }
296
396
 
297
397
  const handleDeleteGroup = (groupId: number) => {
298
398
  if (!window.confirm(labels.groupsView.confirmDelete)) return
299
- fetcher.submit({ intent: "deleteGroup", groupId: String(groupId) }, { method: "post" })
399
+ submitThen({ intent: "deleteGroup", groupId: String(groupId) })
300
400
  }
301
401
 
302
402
  return (
303
403
  <section data-testid="groups-view-section" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
304
- <ActionError fetcher={fetcher} />
305
- <DirectorySyncBanner status={data.directoryStatus} labels={labels} />
404
+ <h2 style={heading}>{labels.groupsView.title}</h2>
405
+ {!(isCreateOpen || editingGroup) && <ActionError fetcher={fetcher} />}
306
406
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", backgroundColor: "#ffffff", padding: "1rem", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
307
407
  <input
308
408
  type="text"
409
+ aria-label={labels.groupsView.searchLabel}
309
410
  placeholder={labels.groupsView.searchPlaceholder}
310
411
  value={searchQuery}
311
412
  onChange={(e) => setSearchQuery(e.target.value)}
@@ -335,25 +436,25 @@ export function GroupsView() {
335
436
  {/* 所属も名称も上流が所有していることを、操作を試す前に伝える */}
336
437
  {g.externalLink && (
337
438
  <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>
439
+ <span style={{ fontSize: "0.72rem", padding: "2px 8px", borderRadius: "10px", backgroundColor: "#ede9fe", color: "#6d28d9", fontWeight: 600 }}>{labels.common.managedUpstream}</span>
339
440
  <span style={{ fontSize: "0.72rem", fontFamily: "monospace", color: "#64748b" }}>{g.externalLink.externalKey}</span>
340
441
  {g.externalLink.unresolvedCount > 0 && (
341
442
  <span data-testid={`group-unresolved-${g.id}`} style={{ fontSize: "0.72rem", color: "#b45309" }}>
342
- {g.externalLink.unresolvedCount} {labels.directoryView.unresolved}
443
+ {fill(labels.directoryView.unresolvedTemplate, { count: g.externalLink.unresolvedCount })}
343
444
  </span>
344
445
  )}
345
446
  </div>
346
447
  )}
347
448
  </div>
348
449
  <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>
450
+ <span style={{ fontSize: "0.78rem", color: "#475569" }} data-testid={`group-member-count-${g.id}`}>
451
+ {fill(labels.groupsView.memberCountTemplate, { count: memberCount })}
351
452
  </span>
352
453
  {/* 上流が所有する行では操作を **描かない**。無効化したボタンを出すと、
353
454
  押せる操作と見分けがつかず、押せない理由も伝わらない */}
354
455
  {g.readOnly ? (
355
- <span data-testid={`readonly-group-${g.id}`} style={{ fontSize: "0.75rem", color: "#94a3b8" }}>
356
- {labels.allowlistView.readOnly}
456
+ <span data-testid={`readonly-group-${g.id}`} style={{ fontSize: "0.75rem", color: "#475569" }}>
457
+ {labels.common.readOnly}
357
458
  </span>
358
459
  ) : (
359
460
  <div>
@@ -362,14 +463,14 @@ export function GroupsView() {
362
463
  onClick={() => setEditingGroup(g)}
363
464
  data-testid={`edit-group-btn-${g.id}`}
364
465
  style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", fontSize: "0.75rem", cursor: "pointer", marginRight: "4px" }}>
365
- {labels.usersView.edit}
466
+ {labels.common.edit}
366
467
  </button>
367
468
  <button
368
469
  type="button"
369
470
  onClick={() => handleDeleteGroup(g.id)}
370
471
  data-testid={`delete-group-btn-${g.id}`}
371
472
  style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #fee2e2", backgroundColor: "#fff5f5", color: "#b91c1c", fontSize: "0.75rem", cursor: "pointer" }}>
372
- {labels.allowlistView.delete}
473
+ {labels.common.delete}
373
474
  </button>
374
475
  </div>
375
476
  )}
@@ -378,12 +479,28 @@ export function GroupsView() {
378
479
  )
379
480
  })}
380
481
  </div>
482
+ {filteredGroups.length === 0 && (
483
+ <div data-testid="groups-empty" style={{ padding: "2rem", textAlign: "center", color: "#475569", backgroundColor: "#ffffff", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
484
+ {data.groups.length === 0 ? labels.groupsView.empty : labels.groupsView.emptyFiltered}
485
+ </div>
486
+ )}
381
487
 
382
488
  {/* Create Group Modal */}
383
489
  {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>
490
+ <div style={{ position: "fixed", inset: 0, backgroundColor: "rgba(0,0,0,0.4)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 100 }}>
491
+ <form
492
+ ref={createGroupRef}
493
+ onSubmit={handleCreateGroup}
494
+ role="dialog"
495
+ aria-modal="true"
496
+ aria-labelledby="create-group-title"
497
+ aria-describedby="create-group-error"
498
+ data-testid="group-create-modal"
499
+ style={{ backgroundColor: "#ffffff", padding: "1.5rem", borderRadius: "8px", width: "360px", boxShadow: "0 10px 25px rgba(0,0,0,0.15)" }}>
500
+ <h3 id="create-group-title" style={{ margin: "0 0 1rem 0", fontSize: "1.1rem" }}>
501
+ {labels.groupsView.addGroup}
502
+ </h3>
503
+ <ActionError fetcher={fetcher} id="create-group-error" />
387
504
  <div style={{ marginBottom: "1rem" }}>
388
505
  <label htmlFor="create-group-key-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
389
506
  {labels.groupsView.groupKey}
@@ -430,7 +547,7 @@ export function GroupsView() {
430
547
  <button type="button" onClick={() => setIsCreateOpen(false)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
431
548
  キャンセル
432
549
  </button>
433
- <button type="submit" data-testid="submit-create-group" style={{ padding: "6px 12px", borderRadius: "4px", backgroundColor: "#4338ca", color: "#ffffff", border: "none", cursor: "pointer" }}>
550
+ <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" }}>
434
551
  作成
435
552
  </button>
436
553
  </div>
@@ -440,9 +557,20 @@ export function GroupsView() {
440
557
 
441
558
  {/* Edit Group Modal */}
442
559
  {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>
560
+ <div style={{ position: "fixed", inset: 0, backgroundColor: "rgba(0,0,0,0.4)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 100 }}>
561
+ <form
562
+ ref={editGroupRef}
563
+ onSubmit={handleUpdateGroup}
564
+ role="dialog"
565
+ aria-modal="true"
566
+ aria-labelledby="edit-group-title"
567
+ aria-describedby="edit-group-error"
568
+ data-testid="group-edit-modal"
569
+ style={{ backgroundColor: "#ffffff", padding: "1.5rem", borderRadius: "8px", width: "360px", boxShadow: "0 10px 25px rgba(0,0,0,0.15)" }}>
570
+ <h3 id="edit-group-title" style={{ margin: "0 0 1rem 0", fontSize: "1.1rem" }}>
571
+ {labels.groupsView.editGroup}
572
+ </h3>
573
+ <ActionError fetcher={fetcher} id="edit-group-error" />
446
574
  <div style={{ marginBottom: "1rem" }}>
447
575
  <label htmlFor="edit-group-name-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
448
576
  {labels.groupsView.name}
@@ -471,10 +599,10 @@ export function GroupsView() {
471
599
  </div>
472
600
  <div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
473
601
  <button type="button" onClick={() => setEditingGroup(null)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
474
- {labels.groupsView.cancel}
602
+ {labels.common.cancel}
475
603
  </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}
604
+ <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" }}>
605
+ {labels.common.save}
478
606
  </button>
479
607
  </div>
480
608
  </form>
@@ -491,6 +619,8 @@ export function GroupsView() {
491
619
  export function GroupMembersView() {
492
620
  const data = useLoaderData() as UserAdminLoaderData
493
621
  const fetcher = useFetcher()
622
+ const submitThen = useSubmitThen(fetcher)
623
+ const busy = fetcher.state !== "idle"
494
624
  const labels = data.labels ?? defaultUserAdminLabels
495
625
 
496
626
  const [selectedGroupId, setSelectedGroupId] = useState<number>(data.groups[0]?.id ?? 0)
@@ -500,14 +630,15 @@ export function GroupMembersView() {
500
630
 
501
631
  const handleToggleMember = (userId: number, isMember: boolean) => {
502
632
  if (isMember) {
503
- fetcher.submit({ intent: "removeGroupMember", groupId: String(selectedGroupId), userId: String(userId) }, { method: "post" })
633
+ submitThen({ intent: "removeGroupMember", groupId: String(selectedGroupId), userId: String(userId) })
504
634
  } else {
505
- fetcher.submit({ intent: "addGroupMember", groupId: String(selectedGroupId), userId: String(userId) }, { method: "post" })
635
+ submitThen({ intent: "addGroupMember", groupId: String(selectedGroupId), userId: String(userId) })
506
636
  }
507
637
  }
508
638
 
509
639
  return (
510
640
  <section data-testid="group-members-view-section" style={{ display: "grid", gridTemplateColumns: "240px 1fr", gap: "1.2rem" }}>
641
+ <h2 style={{ ...heading, gridColumn: "1 / -1" }}>{labels.groupMembersView.title}</h2>
511
642
  <ActionError fetcher={fetcher} />
512
643
  <div style={{ backgroundColor: "#ffffff", padding: "1rem", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
513
644
  <h4 style={{ margin: "0 0 0.8rem 0", fontSize: "0.9rem", color: "#475569" }}>{labels.groupMembersView.selectGroup}</h4>
@@ -532,6 +663,11 @@ export function GroupMembersView() {
532
663
  fontSize: "0.85rem",
533
664
  }}>
534
665
  {g.name}
666
+ {g.readOnly ? (
667
+ <span data-testid={`group-upstream-marker-${g.id}`} style={{ marginLeft: "6px", fontSize: "0.7rem", color: "#7c3aed" }}>
668
+ {labels.common.managedUpstream}
669
+ </span>
670
+ ) : null}
535
671
  </button>
536
672
  )
537
673
  })}
@@ -542,8 +678,15 @@ export function GroupMembersView() {
542
678
  {currentGroup ? (
543
679
  <>
544
680
  <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>
681
+ <h3 style={{ margin: 0, fontSize: "1.1rem", color: "#0f172a" }}>{fill(labels.groupMembersView.membersOfTemplate, { group: currentGroup.name })}</h3>
682
+ <span style={{ fontSize: "0.8rem", color: "#64748b" }}>
683
+ {labels.groupMembersView.groupKeyLabel}: {currentGroup.groupKey}
684
+ </span>
685
+ {currentGroup.readOnly ? (
686
+ <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" }}>
687
+ {labels.groupMembersView.upstreamNotice}
688
+ </div>
689
+ ) : null}
547
690
  </div>
548
691
  <div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
549
692
  {data.users.map((u) => {
@@ -565,29 +708,40 @@ export function GroupMembersView() {
565
708
  <span style={{ fontWeight: 600, fontSize: "0.88rem", color: "#0f172a", marginRight: "8px" }}>{u.displayName}</span>
566
709
  <span style={{ fontSize: "0.78rem", color: "#64748b" }}>({u.email})</span>
567
710
  </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>
711
+ {currentGroup.readOnly ? (
712
+ isMember ? (
713
+ <span data-testid={`member-readonly-${u.id}`} style={{ fontSize: "0.75rem", color: "#475569" }}>
714
+ {labels.common.managedUpstream}
715
+ </span>
716
+ ) : null
717
+ ) : (
718
+ <button
719
+ type="button"
720
+ onClick={() => handleToggleMember(u.id, isMember)}
721
+ disabled={busy}
722
+ aria-busy={busy}
723
+ aria-label={fill(isMember ? labels.groupMembersView.actionRemoveAria : labels.groupMembersView.actionAddAria, { user: u.displayName })}
724
+ data-testid={`toggle-member-btn-${u.id}`}
725
+ style={{
726
+ padding: "4px 12px",
727
+ borderRadius: "4px",
728
+ border: isMember ? "1px solid #b91c1c" : "1px solid #0284c7",
729
+ backgroundColor: isMember ? "#ffffff" : "#0284c7",
730
+ color: isMember ? "#b91c1c" : "#ffffff",
731
+ fontSize: "0.78rem",
732
+ fontWeight: 600,
733
+ cursor: "pointer",
734
+ }}>
735
+ {isMember ? labels.groupMembersView.actionRemove : labels.groupMembersView.actionAdd}
736
+ </button>
737
+ )}
584
738
  </div>
585
739
  )
586
740
  })}
587
741
  </div>
588
742
  </>
589
743
  ) : (
590
- <div style={{ color: "#94a3b8", textAlign: "center", padding: "2rem" }}>{labels.groupMembersView.selectPrompt}</div>
744
+ <div style={{ color: "#475569", textAlign: "center", padding: "2rem" }}>{labels.groupMembersView.selectPrompt}</div>
591
745
  )}
592
746
  </div>
593
747
  </section>
@@ -617,6 +771,8 @@ export function AllowlistView() {
617
771
  const fetcher = useFetcher()
618
772
  const labels = data.labels ?? defaultUserAdminLabels
619
773
 
774
+ const submitThen = useSubmitThen(fetcher)
775
+ const busy = fetcher.state !== "idle"
620
776
  const [pattern, setPattern] = useState("")
621
777
  const [type, setType] = useState<"email" | "domain">("email")
622
778
  const [description, setDescription] = useState("")
@@ -624,22 +780,24 @@ export function AllowlistView() {
624
780
  const handleAddEntry = (e: React.FormEvent) => {
625
781
  e.preventDefault()
626
782
  if (!pattern) return
627
- fetcher.submit({ intent: "addAllowlistEntry", pattern, type, description }, { method: "post" })
628
- setPattern("")
629
- setDescription("")
783
+ submitThen({ intent: "addAllowlistEntry", pattern, type, description }, () => {
784
+ setPattern("")
785
+ setDescription("")
786
+ })
630
787
  }
631
788
 
632
789
  const handleRemoveEntry = (id: number) => {
633
- fetcher.submit({ intent: "removeAllowlistEntry", entryId: String(id) }, { method: "post" })
790
+ submitThen({ intent: "removeAllowlistEntry", entryId: String(id) })
634
791
  }
635
792
 
636
793
  /** グループ許可は別の表に住むため、別の intent で消す。 */
637
794
  const handleRemoveGroupEntry = (id: number) => {
638
- fetcher.submit({ intent: "removeAllowlistGroup", entryId: String(id) }, { method: "post" })
795
+ submitThen({ intent: "removeAllowlistGroup", entryId: String(id) })
639
796
  }
640
797
 
641
798
  return (
642
799
  <section data-testid="allowlist-view-section" style={{ display: "flex", flexDirection: "column", gap: "1.2rem" }}>
800
+ <h2 style={heading}>{labels.allowlistView.title}</h2>
643
801
  <ActionError fetcher={fetcher} />
644
802
  <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
803
  <div style={{ flex: 2, minWidth: "220px" }}>
@@ -683,7 +841,7 @@ export function AllowlistView() {
683
841
  style={{ width: "100%", padding: "6px 10px", borderRadius: "6px", border: "1px solid #cbd5e1", fontSize: "0.85rem", boxSizing: "border-box" }}
684
842
  />
685
843
  </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" }}>
844
+ <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
845
  {labels.allowlistView.addEntry}
688
846
  </button>
689
847
  </form>
@@ -692,22 +850,22 @@ export function AllowlistView() {
692
850
  <table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.85rem", textAlign: "left" }}>
693
851
  <thead>
694
852
  <tr style={{ backgroundColor: "#f8fafc", borderBottom: "1px solid #e2e8f0", color: "#475569" }}>
695
- <th style={{ padding: "10px 14px" }}>ID</th>
853
+ <th style={{ padding: "10px 14px" }}>{labels.common.id}</th>
696
854
  <th style={{ padding: "10px 14px" }}>{labels.allowlistView.target}</th>
697
855
  <th style={{ padding: "10px 14px" }}>{labels.allowlistView.type}</th>
698
856
  <th style={{ padding: "10px 14px" }}>{labels.allowlistView.description}</th>
699
- <th style={{ padding: "10px 14px", textAlign: "right" }}>{labels.allowlistView.actions}</th>
857
+ <th style={{ padding: "10px 14px", textAlign: "right" }}>{labels.common.actions}</th>
700
858
  </tr>
701
859
  </thead>
702
860
  <tbody>
703
861
  {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>
862
+ <tr key={`${item.type}-${item.id}`} style={{ borderBottom: "1px solid #f1f5f9" }} data-testid={`allowlist-row-${item.type}-${item.id}`} data-allowlist-type={item.type}>
863
+ <td style={{ padding: "10px 14px", color: "#475569" }}>{fill(labels.common.idTemplate, { id: item.id })}</td>
706
864
  <td style={{ padding: "10px 14px", fontWeight: 600, color: "#0f172a" }}>
707
865
  {/* パターン許可とグループ許可では正準値が違う。グループはアドレスではなく
708
866
  グループそのものが対象なので、名前とキーを見せる */}
709
867
  {item.type === "group" ? (
710
- <span data-testid={`allowlist-group-${item.id}`}>
868
+ <span data-testid={`allowlist-group-${item.type}-${item.id}`}>
711
869
  {item.groupName}
712
870
  <span style={{ marginLeft: "6px", fontFamily: "monospace", fontSize: "0.78rem", color: "#64748b" }}>{item.groupKey}</span>
713
871
  </span>
@@ -719,10 +877,10 @@ export function AllowlistView() {
719
877
  <span style={{ fontSize: "0.75rem", padding: "2px 8px", borderRadius: "10px", ...allowlistBadgeStyle(item.type), fontWeight: 600 }}>{allowlistTypeLabel(item.type, labels)}</span>
720
878
  </td>
721
879
  <td style={{ padding: "10px 14px", color: "#475569" }}>
722
- {item.description || "-"}
880
+ {item.description || labels.common.none}
723
881
  {/* 配備全体が所有する行は、どのテナントの管理者でも編集できない */}
724
882
  {item.owner.kind === "deployment" && (
725
- <span data-testid={`allowlist-owner-${item.id}`} style={{ marginLeft: "8px", fontSize: "0.72rem", color: "#94a3b8" }}>
883
+ <span data-testid={`allowlist-owner-${item.type}-${item.id}`} style={{ marginLeft: "8px", fontSize: "0.72rem", color: "#475569" }}>
726
884
  {labels.allowlistView.deploymentOwner}
727
885
  </span>
728
886
  )}
@@ -731,16 +889,24 @@ export function AllowlistView() {
731
889
  {/* 変更不能な供給元 (環境変数など) は削除ボタンを出さない。無効化ではなく
732
890
  非描画にするのは、押せないボタンが押せる操作と見分けられないため */}
733
891
  {item.readOnly ? (
734
- <span data-testid={`readonly-allowlist-${item.id}`} style={{ fontSize: "0.75rem", color: "#94a3b8" }}>
735
- {labels.allowlistView.readOnly}
892
+ <span data-testid={`readonly-allowlist-${item.type}-${item.id}`} style={{ fontSize: "0.75rem", color: "#475569" }}>
893
+ {labels.common.readOnly}
736
894
  </span>
737
895
  ) : (
738
896
  <button
739
897
  type="button"
740
- onClick={() => (item.type === "group" ? handleRemoveGroupEntry(item.id) : handleRemoveEntry(item.id))}
741
- data-testid={`remove-allowlist-btn-${item.id}`}
898
+ onClick={() => {
899
+ // これでしかサインインできない人はログインできなくなる。取り消せない
900
+ if (!window.confirm(labels.allowlistView.confirmDelete)) return
901
+ if (item.type === "group") handleRemoveGroupEntry(item.id)
902
+ else handleRemoveEntry(item.id)
903
+ }}
904
+ disabled={busy}
905
+ aria-busy={busy}
906
+ aria-label={fill(labels.allowlistView.deleteAria, { target: item.type === "group" ? item.groupName : item.pattern })}
907
+ data-testid={`remove-allowlist-btn-${item.type}-${item.id}`}
742
908
  style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #fee2e2", backgroundColor: "#fff5f5", color: "#b91c1c", fontSize: "0.78rem", cursor: "pointer" }}>
743
- {labels.allowlistView.delete}
909
+ {labels.common.delete}
744
910
  </button>
745
911
  )}
746
912
  </td>
@@ -748,7 +914,7 @@ export function AllowlistView() {
748
914
  ))}
749
915
  {data.allowlist.length === 0 && (
750
916
  <tr>
751
- <td colSpan={5} style={{ padding: "2rem", textAlign: "center", color: "#94a3b8" }}>
917
+ <td colSpan={5} style={{ padding: "2rem", textAlign: "center", color: "#475569" }}>
752
918
  {labels.allowlistView.empty}
753
919
  </td>
754
920
  </tr>
@@ -760,11 +926,253 @@ export function AllowlistView() {
760
926
  )
761
927
  }
762
928
 
929
+ /**
930
+ * View for the upstream directory: what is linked, how fresh it is, and the controls that change it.
931
+ * 上流ディレクトリのビュー。何が連携され、どれだけ新しく、それを変える操作を提供する。
932
+ *
933
+ * このタブが無いと、連携の作成・解除・一時停止・即時同期・グループ単位のサインイン許可という
934
+ * 5 つの操作にどこからも到達できない。実装されているのに押せる場所が無い状態は、実装が無いのと
935
+ * 変わらないうえ、「許可を消せるが作れない」という片肺の運用を強いる。
936
+ */
937
+ export function DirectoryView() {
938
+ const data = useLoaderData() as UserAdminLoaderData
939
+ const fetcher = useFetcher()
940
+ const submitThen = useSubmitThen(fetcher)
941
+ const labels = data.labels ?? defaultUserAdminLabels
942
+ const L = labels.directoryView
943
+
944
+ const linkable = data.groups.filter((g) => !g.externalLink)
945
+ const [groupId, setGroupId] = useState("")
946
+ const [externalKey, setExternalKey] = useState("")
947
+ const [membershipMode, setMembershipMode] = useState<AuthDirectoryMembershipMode>("direct")
948
+
949
+ const linked = data.groups.filter((g) => g.externalLink)
950
+ const allowedGroupIds = new Set(data.allowlist.filter((e) => e.type === "group").map((e) => e.groupId))
951
+ const allowlistEntryOf = (id: number) => data.allowlist.find((e) => e.type === "group" && e.groupId === id)
952
+ const busy = fetcher.state !== "idle"
953
+ // 「要求しました」を出す対象。押しても何も変わらないと、何度も押される
954
+ const [queuedGroupId, setQueuedGroupId] = useState<number | null>(null)
955
+ const canLink = data.directoryStatus.isEnabled
956
+
957
+ /** 連携を作る。上流キーは運用者が知っている値 (グループアドレス) で受け取る。 */
958
+ const handleLink = (e: React.FormEvent) => {
959
+ e.preventDefault()
960
+ if (!(groupId && externalKey)) return
961
+ // 供給元は配備の設定から来る。ここに特定の値を書くと、別の上流を使う配備で作った
962
+ // リンクが同期側で恒久的に拒否される
963
+ submitThen({ intent: "linkDirectoryGroup", groupId, provider: data.directoryStatus.provider, externalKey, membershipMode }, () => {
964
+ setExternalKey("")
965
+ // 連携済みになった行は候補から消える。選択を残すと、表示は空欄なのに前の
966
+ // グループへ送信される
967
+ setGroupId("")
968
+ })
969
+ }
970
+
971
+ return (
972
+ <section data-testid="directory-view-section" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
973
+ <h2 style={heading}>{L.title}</h2>
974
+ <ActionError fetcher={fetcher} />
975
+
976
+ {!canLink && (
977
+ <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" }}>
978
+ {L.notWiredNotice}
979
+ </p>
980
+ )}
981
+
982
+ {canLink && linkable.length === 0 && (
983
+ <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" }}>
984
+ {L.nothingToLink}
985
+ </p>
986
+ )}
987
+
988
+ {canLink && linkable.length > 0 && (
989
+ <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" }}>
990
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
991
+ <label htmlFor="directory-group-select-input" style={fieldLabel}>
992
+ {L.selectGroup}
993
+ </label>
994
+ <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" }}>
995
+ <option value="">{L.selectGroup}</option>
996
+ {linkable.map((g) => (
997
+ <option key={g.id} value={String(g.id)}>
998
+ {fill(L.groupOptionTemplate, { name: g.name, key: g.groupKey })}
999
+ </option>
1000
+ ))}
1001
+ </select>
1002
+ </div>
1003
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.25rem", flex: 1, minWidth: "220px" }}>
1004
+ <label htmlFor="directory-external-key-input" style={fieldLabel}>
1005
+ {L.externalKey}
1006
+ </label>
1007
+ <input
1008
+ id="directory-external-key-input"
1009
+ type="email"
1010
+ required
1011
+ data-testid="directory-external-key"
1012
+ placeholder={L.externalKeyPlaceholder}
1013
+ value={externalKey}
1014
+ onChange={(e) => setExternalKey(e.target.value)}
1015
+ style={{ padding: "0.5rem", borderRadius: "6px", border: "1px solid #cbd5e1" }}
1016
+ />
1017
+ </div>
1018
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
1019
+ <label htmlFor="directory-membership-mode-input" style={fieldLabel}>
1020
+ {L.membershipMode}
1021
+ </label>
1022
+ <select
1023
+ id="directory-membership-mode-input"
1024
+ data-testid="directory-membership-mode"
1025
+ value={membershipMode}
1026
+ onChange={(e) => setMembershipMode(e.target.value as AuthDirectoryMembershipMode)}
1027
+ style={{ padding: "0.5rem", borderRadius: "6px", border: "1px solid #cbd5e1" }}>
1028
+ <option value="direct">{L.directMode}</option>
1029
+ <option value="transitive">{L.transitiveMode}</option>
1030
+ </select>
1031
+ </div>
1032
+ <button
1033
+ type="submit"
1034
+ data-testid="directory-link-submit"
1035
+ disabled={!(groupId && externalKey) || busy}
1036
+ aria-busy={busy}
1037
+ style={{ padding: "0.5rem 1rem", borderRadius: "6px", border: "none", backgroundColor: groupId && externalKey && !busy ? "#2563eb" : "#94a3b8", color: "#fff", fontWeight: 600 }}>
1038
+ {L.link}
1039
+ </button>
1040
+ </form>
1041
+ )}
1042
+
1043
+ {linked.length === 0 ? (
1044
+ <div data-testid="directory-empty" style={{ padding: "2rem", textAlign: "center", color: "#64748b", backgroundColor: "#ffffff", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
1045
+ {L.empty}
1046
+ </div>
1047
+ ) : (
1048
+ <table data-testid="directory-table" style={{ width: "100%", borderCollapse: "collapse", backgroundColor: "#ffffff", borderRadius: "8px", overflow: "hidden", border: "1px solid #e2e8f0" }}>
1049
+ <thead>
1050
+ <tr style={{ backgroundColor: "#f8fafc", textAlign: "left" }}>
1051
+ <th style={cell}>{labels.groupsView.name}</th>
1052
+ <th style={cell}>{L.externalKey}</th>
1053
+ <th style={cell}>{L.membershipMode}</th>
1054
+ <th style={cell}>{L.lastSynced}</th>
1055
+ <th style={cell}>{labels.common.status}</th>
1056
+ <th style={cell}>{labels.common.actions}</th>
1057
+ </tr>
1058
+ </thead>
1059
+ <tbody>
1060
+ {linked.map((g) => {
1061
+ const link = g.externalLink as NonNullable<AuthGroup["externalLink"]>
1062
+ const allowed = allowedGroupIds.has(g.id)
1063
+ return (
1064
+ <tr key={g.id} data-testid={`directory-row-${g.id}`} data-sync-enabled={String(link.isSyncEnabled)} style={{ borderTop: "1px solid #e2e8f0" }}>
1065
+ <td style={cell}>
1066
+ {g.name}
1067
+ <div style={{ fontSize: "0.75rem", color: "#64748b" }}>{g.groupKey}</div>
1068
+ </td>
1069
+ <td style={cell} data-testid={`directory-external-${g.id}`}>
1070
+ {link.externalKey}
1071
+ </td>
1072
+ <td style={cell}>{link.membershipMode === "direct" ? L.directMode : L.transitiveMode}</td>
1073
+ <td style={cell} data-testid={`directory-synced-${g.id}`}>
1074
+ {link.syncedAt ? formatDateTime(link.syncedAt, labels) : L.neverSynced}
1075
+ {link.unresolvedCount > 0 ? (
1076
+ <div style={{ fontSize: "0.75rem", color: "#b45309" }} data-testid={`directory-unresolved-${g.id}`}>
1077
+ {fill(L.unresolvedTemplate, { count: link.unresolvedCount })}
1078
+ </div>
1079
+ ) : null}
1080
+ </td>
1081
+ <td style={cell}>
1082
+ <span
1083
+ data-testid={`directory-status-${g.id}`}
1084
+ style={{ fontSize: "0.75rem", padding: "2px 8px", borderRadius: "10px", backgroundColor: link.isSyncEnabled ? "#dcfce7" : "#fee2e2", color: link.isSyncEnabled ? "#166534" : "#b91c1c", fontWeight: 600 }}>
1085
+ {link.isSyncEnabled ? L.syncEnabled : L.syncPaused}
1086
+ </span>
1087
+ {link.syncError ? (
1088
+ <div style={{ fontSize: "0.75rem", color: "#b91c1c", marginTop: "0.25rem" }} data-testid={`directory-error-${g.id}`}>
1089
+ {link.syncError}
1090
+ </div>
1091
+ ) : null}
1092
+ </td>
1093
+ {/* td 自体を flex にすると表セルの役割が失われ、列と見出しの対応が切れる */}
1094
+ <td style={cell}>
1095
+ <div style={{ display: "flex", gap: "0.35rem", flexWrap: "wrap" }}>
1096
+ <button
1097
+ type="button"
1098
+ data-testid={`directory-sync-now-${g.id}`}
1099
+ disabled={busy}
1100
+ aria-busy={busy}
1101
+ aria-label={fill(L.syncNowAria, { group: g.name })}
1102
+ onClick={() => submitThen({ intent: "requestDirectorySync", groupId: String(g.id) }, () => setQueuedGroupId(g.id))}
1103
+ style={smallButton}>
1104
+ {queuedGroupId === g.id ? L.syncQueued : L.syncNow}
1105
+ </button>
1106
+ <button
1107
+ type="button"
1108
+ data-testid={`directory-toggle-sync-${g.id}`}
1109
+ disabled={busy}
1110
+ aria-busy={busy}
1111
+ aria-label={fill(link.isSyncEnabled ? L.pauseAria : L.resumeAria, { group: g.name })}
1112
+ onClick={() => submitThen({ intent: "setDirectorySyncEnabled", groupId: String(g.id), isSyncEnabled: String(!link.isSyncEnabled) })}
1113
+ style={smallButton}>
1114
+ {link.isSyncEnabled ? L.pause : L.resume}
1115
+ </button>
1116
+ <button
1117
+ type="button"
1118
+ data-testid={`directory-allow-${g.id}`}
1119
+ disabled={busy}
1120
+ aria-busy={busy}
1121
+ aria-label={fill(allowed ? L.disallowGroupAria : L.allowGroupAria, { group: g.name })}
1122
+ onClick={() => {
1123
+ const entry = allowlistEntryOf(g.id)
1124
+ if (allowed && entry) {
1125
+ // 取り消しはサインインを止める。追加は元に戻すだけなので確認しない
1126
+ if (!window.confirm(L.confirmDisallow)) return
1127
+ submitThen({ intent: "removeAllowlistGroup", entryId: String(entry.id) })
1128
+ } else {
1129
+ submitThen({ intent: "addAllowlistGroup", groupId: String(g.id) })
1130
+ }
1131
+ }}
1132
+ style={smallButton}>
1133
+ {allowed ? L.disallowGroup : L.allowGroup}
1134
+ </button>
1135
+ <button
1136
+ type="button"
1137
+ data-testid={`directory-unlink-${g.id}`}
1138
+ disabled={busy}
1139
+ aria-busy={busy}
1140
+ aria-label={fill(L.unlinkAria, { group: g.name })}
1141
+ onClick={() => {
1142
+ // 台帳とグループ単位の許可が失われる。取り消せないので必ず確認する
1143
+ if (!window.confirm(L.confirmUnlink)) return
1144
+ submitThen({ intent: "unlinkDirectoryGroup", groupId: String(g.id) })
1145
+ }}
1146
+ style={{ ...smallButton, color: "#b91c1c", borderColor: "#fecaca" }}>
1147
+ {L.unlink}
1148
+ </button>
1149
+ </div>
1150
+ </td>
1151
+ </tr>
1152
+ )
1153
+ })}
1154
+ </tbody>
1155
+ </table>
1156
+ )}
1157
+ </section>
1158
+ )
1159
+ }
1160
+
1161
+ /** フォーム項目の見出し。プレースホルダは入力すると消えるため名前にならない。 */
1162
+ const fieldLabel: React.CSSProperties = { fontSize: "0.78rem", color: "#475569", fontWeight: 600 }
1163
+ /** 各ビューの見出し。段階を飛ばさないよう h2 に固定する。 */
1164
+ const heading: React.CSSProperties = { margin: 0, fontSize: "1.05rem", fontWeight: 700, color: "#0f172a" }
1165
+ /** 表セルの共通スタイル。 */
1166
+ const cell: React.CSSProperties = { padding: "0.6rem 0.75rem", fontSize: "0.85rem", verticalAlign: "top" }
1167
+ /** 行内の小さな操作ボタン。 */
1168
+ const smallButton: React.CSSProperties = { padding: "0.25rem 0.6rem", fontSize: "0.75rem", borderRadius: "6px", border: "1px solid #cbd5e1", backgroundColor: "#fff", cursor: "pointer" }
1169
+
763
1170
  const SEGMENT_VIEWS: Record<string, () => React.ReactElement> = {
764
1171
  users: UsersView,
765
1172
  groups: GroupsView,
766
1173
  group_members: GroupMembersView,
767
1174
  allowlist: AllowlistView,
1175
+ directory: DirectoryView,
768
1176
  }
769
1177
 
770
1178
  export type AuthUserAdminAppViewProps = UserAdminShellProps