@aiquants/auth-react-router 0.9.1 → 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, 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
 
@@ -11,6 +12,7 @@ export type UserAdminLoaderData = {
11
12
  groups: AuthGroup[]
12
13
  groupMembers: AuthGroupMember[]
13
14
  allowlist: AuthAllowlistEntry[]
15
+ directoryStatus: AuthDirectoryStatus
14
16
  labels?: UserAdminLabels
15
17
  }
16
18
 
@@ -25,19 +27,107 @@ export type UserAdminLoaderData = {
25
27
  * ロックアウト理由・重複パターン・入力不正などの文言は action が `{ok:false,error}` で返す。
26
28
  * 表示しなければ操作が黙って失敗したように見えるため、各ビューの先頭に必ず置く。
27
29
  */
28
- function ActionError({ fetcher }: { fetcher: { data?: unknown } }) {
30
+ function ActionError({ fetcher, id }: { fetcher: { data?: unknown }; id?: string }) {
29
31
  const result = fetcher.data as { ok?: boolean; error?: string } | undefined
30
32
  if (result?.ok !== false || !result.error) return null
31
33
  return (
32
- <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" }}>
33
36
  {result.error}
34
37
  </div>
35
38
  )
36
39
  }
37
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
+
38
127
  export function UsersView() {
39
128
  const data = useLoaderData() as UserAdminLoaderData
40
129
  const fetcher = useFetcher()
130
+ const submitThen = useSubmitThen(fetcher)
41
131
  const labels = data.labels ?? defaultUserAdminLabels
42
132
 
43
133
  const [searchQuery, setSearchQuery] = useState("")
@@ -46,35 +136,43 @@ export function UsersView() {
46
136
 
47
137
  const [newDisplayName, setNewDisplayName] = useState("")
48
138
  const [newEmail, setNewEmail] = useState("")
139
+ const createUserRef = useModalBehavior(isCreateOpen, () => setIsCreateOpen(false))
140
+ const editUserRef = useModalBehavior(editingUser !== null, () => setEditingUser(null))
49
141
 
142
+ const busy = fetcher.state !== "idle"
50
143
  const filteredUsers = data.users.filter((u) => u.displayName.toLowerCase().includes(searchQuery.toLowerCase()) || u.email.toLowerCase().includes(searchQuery.toLowerCase()))
51
144
 
52
145
  const handleToggleActive = (user: AuthUser) => {
53
- 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) })
54
149
  }
55
150
 
56
151
  const handleCreateUser = (e: React.FormEvent) => {
57
152
  e.preventDefault()
58
153
  if (!(newDisplayName && newEmail)) return
59
- fetcher.submit({ intent: "createUser", displayName: newDisplayName, email: newEmail }, { method: "post" })
60
- setNewDisplayName("")
61
- setNewEmail("")
62
- setIsCreateOpen(false)
154
+ submitThen({ intent: "createUser", displayName: newDisplayName, email: newEmail }, () => {
155
+ setNewDisplayName("")
156
+ setNewEmail("")
157
+ setIsCreateOpen(false)
158
+ })
63
159
  }
64
160
 
65
161
  const handleUpdateUser = (e: React.FormEvent) => {
66
162
  e.preventDefault()
67
163
  if (!editingUser) return
68
- fetcher.submit({ intent: "updateUser", userId: String(editingUser.id), displayName: editingUser.displayName, email: editingUser.email }, { method: "post" })
69
- setEditingUser(null)
164
+ submitThen({ intent: "updateUser", userId: String(editingUser.id), displayName: editingUser.displayName, email: editingUser.email }, () => setEditingUser(null))
70
165
  }
71
166
 
72
167
  return (
73
168
  <section data-testid="users-view-section" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
74
- <ActionError fetcher={fetcher} />
169
+ <h2 style={heading}>{labels.usersView.title}</h2>
170
+ {/* モーダルが開いている間はモーダル内に描く。両方描くと同じ hook が 2 つになる */}
171
+ {!(isCreateOpen || editingUser) && <ActionError fetcher={fetcher} />}
75
172
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", backgroundColor: "#ffffff", padding: "1rem", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
76
173
  <input
77
174
  type="text"
175
+ aria-label={labels.usersView.searchLabel}
78
176
  placeholder={labels.usersView.searchPlaceholder}
79
177
  value={searchQuery}
80
178
  onChange={(e) => setSearchQuery(e.target.value)}
@@ -90,17 +188,17 @@ export function UsersView() {
90
188
  <table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.85rem", textAlign: "left" }}>
91
189
  <thead>
92
190
  <tr style={{ backgroundColor: "#f8fafc", borderBottom: "1px solid #e2e8f0", color: "#475569" }}>
93
- <th style={{ padding: "10px 14px" }}>{labels.usersView.id}</th>
191
+ <th style={{ padding: "10px 14px" }}>{labels.common.id}</th>
94
192
  <th style={{ padding: "10px 14px" }}>{labels.usersView.displayName}</th>
95
193
  <th style={{ padding: "10px 14px" }}>{labels.usersView.email}</th>
96
- <th style={{ padding: "10px 14px" }}>{labels.usersView.status}</th>
97
- <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>
98
196
  </tr>
99
197
  </thead>
100
198
  <tbody>
101
199
  {filteredUsers.map((u) => (
102
200
  <tr key={u.id} style={{ borderBottom: "1px solid #f1f5f9" }} data-testid={`user-row-${u.id}`}>
103
- <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>
104
202
  <td style={{ padding: "10px 14px", fontWeight: 600, color: "#0f172a" }}>{u.displayName}</td>
105
203
  <td style={{ padding: "10px 14px", color: "#475569" }}>{u.email}</td>
106
204
  <td style={{ padding: "10px 14px" }}>
@@ -112,15 +210,19 @@ export function UsersView() {
112
210
  <button
113
211
  type="button"
114
212
  onClick={() => setEditingUser(u)}
213
+ aria-label={fill(labels.usersView.editUserAria, { user: u.displayName })}
115
214
  data-testid={`edit-user-btn-${u.id}`}
116
215
  style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", fontSize: "0.78rem", cursor: "pointer", marginRight: "6px" }}>
117
- {labels.usersView.edit}
216
+ {labels.common.edit}
118
217
  </button>
119
218
  <button
120
219
  type="button"
121
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 })}
122
224
  data-testid={`toggle-user-btn-${u.id}`}
123
- 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" }}>
124
226
  {u.isActive ? labels.usersView.deactivate : labels.usersView.activate}
125
227
  </button>
126
228
  </td>
@@ -128,7 +230,7 @@ export function UsersView() {
128
230
  ))}
129
231
  {filteredUsers.length === 0 && (
130
232
  <tr>
131
- <td colSpan={5} style={{ padding: "2rem", textAlign: "center", color: "#94a3b8" }}>
233
+ <td colSpan={5} style={{ padding: "2rem", textAlign: "center", color: "#475569" }}>
132
234
  {labels.usersView.empty}
133
235
  </td>
134
236
  </tr>
@@ -139,9 +241,20 @@ export function UsersView() {
139
241
 
140
242
  {/* Create Modal */}
141
243
  {isCreateOpen && (
142
- <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">
143
- <form onSubmit={handleCreateUser} style={{ backgroundColor: "#ffffff", padding: "1.5rem", borderRadius: "8px", width: "360px", boxShadow: "0 10px 25px rgba(0,0,0,0.15)" }}>
144
- <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" />
145
258
  <div style={{ marginBottom: "1rem" }}>
146
259
  <label htmlFor="create-user-display-name-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
147
260
  {labels.usersView.displayName}
@@ -174,7 +287,7 @@ export function UsersView() {
174
287
  <button type="button" onClick={() => setIsCreateOpen(false)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
175
288
  キャンセル
176
289
  </button>
177
- <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" }}>
178
291
  作成
179
292
  </button>
180
293
  </div>
@@ -184,9 +297,20 @@ export function UsersView() {
184
297
 
185
298
  {/* Edit Modal */}
186
299
  {editingUser && (
187
- <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">
188
- <form onSubmit={handleUpdateUser} style={{ backgroundColor: "#ffffff", padding: "1.5rem", borderRadius: "8px", width: "360px", boxShadow: "0 10px 25px rgba(0,0,0,0.15)" }}>
189
- <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" />
190
314
  <div style={{ marginBottom: "1rem" }}>
191
315
  <label htmlFor="edit-user-display-name-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
192
316
  {labels.usersView.displayName}
@@ -217,10 +341,10 @@ export function UsersView() {
217
341
  </div>
218
342
  <div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
219
343
  <button type="button" onClick={() => setEditingUser(null)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
220
- {labels.usersView.cancel}
344
+ {labels.common.cancel}
221
345
  </button>
222
- <button type="submit" data-testid="submit-edit-user" style={{ padding: "6px 12px", borderRadius: "4px", backgroundColor: "#0284c7", color: "#ffffff", border: "none", cursor: "pointer" }}>
223
- {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}
224
348
  </button>
225
349
  </div>
226
350
  </form>
@@ -237,46 +361,52 @@ export function UsersView() {
237
361
  export function GroupsView() {
238
362
  const data = useLoaderData() as UserAdminLoaderData
239
363
  const fetcher = useFetcher()
364
+ const submitThen = useSubmitThen(fetcher)
240
365
  const labels = data.labels ?? defaultUserAdminLabels
241
366
 
242
367
  const [searchQuery, setSearchQuery] = useState("")
243
368
  const [isCreateOpen, setIsCreateOpen] = useState(false)
244
369
  const [editingGroup, setEditingGroup] = useState<AuthGroup | null>(null)
245
370
 
371
+ const busy = fetcher.state !== "idle"
246
372
  const [newGroupKey, setNewGroupKey] = useState("")
247
373
  const [newName, setNewName] = useState("")
248
374
  const [newDescription, setNewDescription] = useState("")
375
+ const createGroupRef = useModalBehavior(isCreateOpen, () => setIsCreateOpen(false))
376
+ const editGroupRef = useModalBehavior(editingGroup !== null, () => setEditingGroup(null))
249
377
 
250
378
  const filteredGroups = data.groups.filter((g) => g.name.toLowerCase().includes(searchQuery.toLowerCase()) || g.groupKey.toLowerCase().includes(searchQuery.toLowerCase()))
251
379
 
252
380
  const handleCreateGroup = (e: React.FormEvent) => {
253
381
  e.preventDefault()
254
382
  if (!(newGroupKey && newName)) return
255
- fetcher.submit({ intent: "createGroup", groupKey: newGroupKey, name: newName, description: newDescription }, { method: "post" })
256
- setNewGroupKey("")
257
- setNewName("")
258
- setNewDescription("")
259
- setIsCreateOpen(false)
383
+ submitThen({ intent: "createGroup", groupKey: newGroupKey, name: newName, description: newDescription }, () => {
384
+ setNewGroupKey("")
385
+ setNewName("")
386
+ setNewDescription("")
387
+ setIsCreateOpen(false)
388
+ })
260
389
  }
261
390
 
262
391
  const handleUpdateGroup = (e: React.FormEvent) => {
263
392
  e.preventDefault()
264
393
  if (!editingGroup) return
265
- fetcher.submit({ intent: "updateGroup", groupId: String(editingGroup.id), name: editingGroup.name, description: editingGroup.description ?? "" }, { method: "post" })
266
- setEditingGroup(null)
394
+ submitThen({ intent: "updateGroup", groupId: String(editingGroup.id), name: editingGroup.name, description: editingGroup.description ?? "" }, () => setEditingGroup(null))
267
395
  }
268
396
 
269
397
  const handleDeleteGroup = (groupId: number) => {
270
398
  if (!window.confirm(labels.groupsView.confirmDelete)) return
271
- fetcher.submit({ intent: "deleteGroup", groupId: String(groupId) }, { method: "post" })
399
+ submitThen({ intent: "deleteGroup", groupId: String(groupId) })
272
400
  }
273
401
 
274
402
  return (
275
403
  <section data-testid="groups-view-section" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
276
- <ActionError fetcher={fetcher} />
404
+ <h2 style={heading}>{labels.groupsView.title}</h2>
405
+ {!(isCreateOpen || editingGroup) && <ActionError fetcher={fetcher} />}
277
406
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", backgroundColor: "#ffffff", padding: "1rem", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
278
407
  <input
279
408
  type="text"
409
+ aria-label={labels.groupsView.searchLabel}
280
410
  placeholder={labels.groupsView.searchPlaceholder}
281
411
  value={searchQuery}
282
412
  onChange={(e) => setSearchQuery(e.target.value)}
@@ -292,45 +422,85 @@ export function GroupsView() {
292
422
  {filteredGroups.map((g) => {
293
423
  const memberCount = data.groupMembers.filter((m) => m.groupId === g.id).length
294
424
  return (
295
- <div key={g.id} style={{ backgroundColor: "#ffffff", borderRadius: "8px", border: "1px solid #e2e8f0", padding: "1rem", display: "flex", flexDirection: "column", justifyContent: "space-between" }} data-testid={`group-card-${g.id}`}>
425
+ <div
426
+ key={g.id}
427
+ style={{ backgroundColor: "#ffffff", borderRadius: "8px", border: "1px solid #e2e8f0", padding: "1rem", display: "flex", flexDirection: "column", justifyContent: "space-between" }}
428
+ data-testid={`group-card-${g.id}`}
429
+ data-group-readonly={g.readOnly ? "true" : "false"}>
296
430
  <div>
297
431
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "0.5rem" }}>
298
432
  <h4 style={{ margin: 0, fontSize: "1rem", color: "#0f172a" }}>{g.name}</h4>
299
433
  <span style={{ fontSize: "0.72rem", backgroundColor: "#e0e7ff", color: "#4338ca", padding: "2px 6px", borderRadius: "4px", fontFamily: "monospace" }}>{g.groupKey}</span>
300
434
  </div>
301
435
  <p style={{ margin: "0 0 0.8rem 0", fontSize: "0.82rem", color: "#64748b", minHeight: "2.4em" }}>{g.description || labels.groupsView.noDescription}</p>
436
+ {/* 所属も名称も上流が所有していることを、操作を試す前に伝える */}
437
+ {g.externalLink && (
438
+ <div data-testid={`group-source-${g.id}`} style={{ display: "flex", alignItems: "center", gap: "6px", flexWrap: "wrap", marginBottom: "0.6rem" }}>
439
+ <span style={{ fontSize: "0.72rem", padding: "2px 8px", borderRadius: "10px", backgroundColor: "#ede9fe", color: "#6d28d9", fontWeight: 600 }}>{labels.common.managedUpstream}</span>
440
+ <span style={{ fontSize: "0.72rem", fontFamily: "monospace", color: "#64748b" }}>{g.externalLink.externalKey}</span>
441
+ {g.externalLink.unresolvedCount > 0 && (
442
+ <span data-testid={`group-unresolved-${g.id}`} style={{ fontSize: "0.72rem", color: "#b45309" }}>
443
+ {fill(labels.directoryView.unresolvedTemplate, { count: g.externalLink.unresolvedCount })}
444
+ </span>
445
+ )}
446
+ </div>
447
+ )}
302
448
  </div>
303
449
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", borderTop: "1px solid #f1f5f9", paddingTop: "0.6rem" }}>
304
- <span style={{ fontSize: "0.78rem", color: "#475569" }}>
305
- 👥 メンバー: <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 })}
306
452
  </span>
307
- <div>
308
- <button
309
- type="button"
310
- onClick={() => setEditingGroup(g)}
311
- data-testid={`edit-group-btn-${g.id}`}
312
- style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", fontSize: "0.75rem", cursor: "pointer", marginRight: "4px" }}>
313
- {labels.usersView.edit}
314
- </button>
315
- <button
316
- type="button"
317
- onClick={() => handleDeleteGroup(g.id)}
318
- data-testid={`delete-group-btn-${g.id}`}
319
- style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #fee2e2", backgroundColor: "#fff5f5", color: "#b91c1c", fontSize: "0.75rem", cursor: "pointer" }}>
320
- 削除
321
- </button>
322
- </div>
453
+ {/* 上流が所有する行では操作を **描かない**。無効化したボタンを出すと、
454
+ 押せる操作と見分けがつかず、押せない理由も伝わらない */}
455
+ {g.readOnly ? (
456
+ <span data-testid={`readonly-group-${g.id}`} style={{ fontSize: "0.75rem", color: "#475569" }}>
457
+ {labels.common.readOnly}
458
+ </span>
459
+ ) : (
460
+ <div>
461
+ <button
462
+ type="button"
463
+ onClick={() => setEditingGroup(g)}
464
+ data-testid={`edit-group-btn-${g.id}`}
465
+ style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", fontSize: "0.75rem", cursor: "pointer", marginRight: "4px" }}>
466
+ {labels.common.edit}
467
+ </button>
468
+ <button
469
+ type="button"
470
+ onClick={() => handleDeleteGroup(g.id)}
471
+ data-testid={`delete-group-btn-${g.id}`}
472
+ style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #fee2e2", backgroundColor: "#fff5f5", color: "#b91c1c", fontSize: "0.75rem", cursor: "pointer" }}>
473
+ {labels.common.delete}
474
+ </button>
475
+ </div>
476
+ )}
323
477
  </div>
324
478
  </div>
325
479
  )
326
480
  })}
327
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
+ )}
328
487
 
329
488
  {/* Create Group Modal */}
330
489
  {isCreateOpen && (
331
- <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">
332
- <form onSubmit={handleCreateGroup} style={{ backgroundColor: "#ffffff", padding: "1.5rem", borderRadius: "8px", width: "360px", boxShadow: "0 10px 25px rgba(0,0,0,0.15)" }}>
333
- <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" />
334
504
  <div style={{ marginBottom: "1rem" }}>
335
505
  <label htmlFor="create-group-key-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
336
506
  {labels.groupsView.groupKey}
@@ -377,7 +547,7 @@ export function GroupsView() {
377
547
  <button type="button" onClick={() => setIsCreateOpen(false)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
378
548
  キャンセル
379
549
  </button>
380
- <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" }}>
381
551
  作成
382
552
  </button>
383
553
  </div>
@@ -387,9 +557,20 @@ export function GroupsView() {
387
557
 
388
558
  {/* Edit Group Modal */}
389
559
  {editingGroup && (
390
- <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">
391
- <form onSubmit={handleUpdateGroup} style={{ backgroundColor: "#ffffff", padding: "1.5rem", borderRadius: "8px", width: "360px", boxShadow: "0 10px 25px rgba(0,0,0,0.15)" }}>
392
- <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" />
393
574
  <div style={{ marginBottom: "1rem" }}>
394
575
  <label htmlFor="edit-group-name-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
395
576
  {labels.groupsView.name}
@@ -418,10 +599,10 @@ export function GroupsView() {
418
599
  </div>
419
600
  <div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
420
601
  <button type="button" onClick={() => setEditingGroup(null)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
421
- {labels.groupsView.cancel}
602
+ {labels.common.cancel}
422
603
  </button>
423
- <button type="submit" data-testid="submit-edit-group" style={{ padding: "6px 12px", borderRadius: "4px", backgroundColor: "#4338ca", color: "#ffffff", border: "none", cursor: "pointer" }}>
424
- {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}
425
606
  </button>
426
607
  </div>
427
608
  </form>
@@ -438,6 +619,8 @@ export function GroupsView() {
438
619
  export function GroupMembersView() {
439
620
  const data = useLoaderData() as UserAdminLoaderData
440
621
  const fetcher = useFetcher()
622
+ const submitThen = useSubmitThen(fetcher)
623
+ const busy = fetcher.state !== "idle"
441
624
  const labels = data.labels ?? defaultUserAdminLabels
442
625
 
443
626
  const [selectedGroupId, setSelectedGroupId] = useState<number>(data.groups[0]?.id ?? 0)
@@ -447,14 +630,15 @@ export function GroupMembersView() {
447
630
 
448
631
  const handleToggleMember = (userId: number, isMember: boolean) => {
449
632
  if (isMember) {
450
- fetcher.submit({ intent: "removeGroupMember", groupId: String(selectedGroupId), userId: String(userId) }, { method: "post" })
633
+ submitThen({ intent: "removeGroupMember", groupId: String(selectedGroupId), userId: String(userId) })
451
634
  } else {
452
- fetcher.submit({ intent: "addGroupMember", groupId: String(selectedGroupId), userId: String(userId) }, { method: "post" })
635
+ submitThen({ intent: "addGroupMember", groupId: String(selectedGroupId), userId: String(userId) })
453
636
  }
454
637
  }
455
638
 
456
639
  return (
457
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>
458
642
  <ActionError fetcher={fetcher} />
459
643
  <div style={{ backgroundColor: "#ffffff", padding: "1rem", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
460
644
  <h4 style={{ margin: "0 0 0.8rem 0", fontSize: "0.9rem", color: "#475569" }}>{labels.groupMembersView.selectGroup}</h4>
@@ -479,6 +663,11 @@ export function GroupMembersView() {
479
663
  fontSize: "0.85rem",
480
664
  }}>
481
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}
482
671
  </button>
483
672
  )
484
673
  })}
@@ -489,8 +678,15 @@ export function GroupMembersView() {
489
678
  {currentGroup ? (
490
679
  <>
491
680
  <div style={{ marginBottom: "1rem", borderBottom: "1px solid #e2e8f0", paddingBottom: "0.8rem" }}>
492
- <h3 style={{ margin: 0, fontSize: "1.1rem", color: "#0f172a" }}>{currentGroup.name} の所属メンバー</h3>
493
- <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}
494
690
  </div>
495
691
  <div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
496
692
  {data.users.map((u) => {
@@ -512,35 +708,60 @@ export function GroupMembersView() {
512
708
  <span style={{ fontWeight: 600, fontSize: "0.88rem", color: "#0f172a", marginRight: "8px" }}>{u.displayName}</span>
513
709
  <span style={{ fontSize: "0.78rem", color: "#64748b" }}>({u.email})</span>
514
710
  </div>
515
- <button
516
- type="button"
517
- onClick={() => handleToggleMember(u.id, isMember)}
518
- data-testid={`toggle-member-btn-${u.id}`}
519
- style={{
520
- padding: "4px 12px",
521
- borderRadius: "4px",
522
- border: isMember ? "1px solid #b91c1c" : "1px solid #0284c7",
523
- backgroundColor: isMember ? "#ffffff" : "#0284c7",
524
- color: isMember ? "#b91c1c" : "#ffffff",
525
- fontSize: "0.78rem",
526
- fontWeight: 600,
527
- cursor: "pointer",
528
- }}>
529
- {isMember ? labels.groupMembersView.actionRemove : labels.groupMembersView.actionAdd}
530
- </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
+ )}
531
738
  </div>
532
739
  )
533
740
  })}
534
741
  </div>
535
742
  </>
536
743
  ) : (
537
- <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>
538
745
  )}
539
746
  </div>
540
747
  </section>
541
748
  )
542
749
  }
543
750
 
751
+ /** Badge colours per allowlist kind. 許可種別ごとのバッジ配色。 */
752
+ function allowlistBadgeStyle(type: AuthAllowlistEntry["type"]): { backgroundColor: string; color: string } {
753
+ if (type === "email") return { backgroundColor: "#e0f2fe", color: "#0369a1" }
754
+ if (type === "domain") return { backgroundColor: "#fef3c7", color: "#b45309" }
755
+ return { backgroundColor: "#ede9fe", color: "#6d28d9" }
756
+ }
757
+
758
+ /** Badge text per allowlist kind. 許可種別ごとのバッジ文言。 */
759
+ function allowlistTypeLabel(type: AuthAllowlistEntry["type"], labels: UserAdminLabels): string {
760
+ if (type === "email") return labels.allowlistView.emailTypeShort
761
+ if (type === "domain") return labels.allowlistView.domainTypeShort
762
+ return labels.allowlistView.groupTypeShort
763
+ }
764
+
544
765
  /**
545
766
  * View for managing authentication allowlist entries.
546
767
  * アクセス許可リスト (Allowlist) 管理ビュー。
@@ -550,6 +771,8 @@ export function AllowlistView() {
550
771
  const fetcher = useFetcher()
551
772
  const labels = data.labels ?? defaultUserAdminLabels
552
773
 
774
+ const submitThen = useSubmitThen(fetcher)
775
+ const busy = fetcher.state !== "idle"
553
776
  const [pattern, setPattern] = useState("")
554
777
  const [type, setType] = useState<"email" | "domain">("email")
555
778
  const [description, setDescription] = useState("")
@@ -557,17 +780,24 @@ export function AllowlistView() {
557
780
  const handleAddEntry = (e: React.FormEvent) => {
558
781
  e.preventDefault()
559
782
  if (!pattern) return
560
- fetcher.submit({ intent: "addAllowlistEntry", pattern, type, description }, { method: "post" })
561
- setPattern("")
562
- setDescription("")
783
+ submitThen({ intent: "addAllowlistEntry", pattern, type, description }, () => {
784
+ setPattern("")
785
+ setDescription("")
786
+ })
563
787
  }
564
788
 
565
789
  const handleRemoveEntry = (id: number) => {
566
- fetcher.submit({ intent: "removeAllowlistEntry", entryId: String(id) }, { method: "post" })
790
+ submitThen({ intent: "removeAllowlistEntry", entryId: String(id) })
791
+ }
792
+
793
+ /** グループ許可は別の表に住むため、別の intent で消す。 */
794
+ const handleRemoveGroupEntry = (id: number) => {
795
+ submitThen({ intent: "removeAllowlistGroup", entryId: String(id) })
567
796
  }
568
797
 
569
798
  return (
570
799
  <section data-testid="allowlist-view-section" style={{ display: "flex", flexDirection: "column", gap: "1.2rem" }}>
800
+ <h2 style={heading}>{labels.allowlistView.title}</h2>
571
801
  <ActionError fetcher={fetcher} />
572
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" }}>
573
803
  <div style={{ flex: 2, minWidth: "220px" }}>
@@ -611,7 +841,7 @@ export function AllowlistView() {
611
841
  style={{ width: "100%", padding: "6px 10px", borderRadius: "6px", border: "1px solid #cbd5e1", fontSize: "0.85rem", boxSizing: "border-box" }}
612
842
  />
613
843
  </div>
614
- <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" }}>
615
845
  {labels.allowlistView.addEntry}
616
846
  </button>
617
847
  </form>
@@ -620,37 +850,63 @@ export function AllowlistView() {
620
850
  <table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.85rem", textAlign: "left" }}>
621
851
  <thead>
622
852
  <tr style={{ backgroundColor: "#f8fafc", borderBottom: "1px solid #e2e8f0", color: "#475569" }}>
623
- <th style={{ padding: "10px 14px" }}>ID</th>
624
- <th style={{ padding: "10px 14px" }}>{labels.allowlistView.pattern}</th>
853
+ <th style={{ padding: "10px 14px" }}>{labels.common.id}</th>
854
+ <th style={{ padding: "10px 14px" }}>{labels.allowlistView.target}</th>
625
855
  <th style={{ padding: "10px 14px" }}>{labels.allowlistView.type}</th>
626
856
  <th style={{ padding: "10px 14px" }}>{labels.allowlistView.description}</th>
627
- <th style={{ padding: "10px 14px", textAlign: "right" }}>{labels.allowlistView.actions}</th>
857
+ <th style={{ padding: "10px 14px", textAlign: "right" }}>{labels.common.actions}</th>
628
858
  </tr>
629
859
  </thead>
630
860
  <tbody>
631
861
  {data.allowlist.map((item) => (
632
- <tr key={item.id} style={{ borderBottom: "1px solid #f1f5f9" }} data-testid={`allowlist-row-${item.id}`}>
633
- <td style={{ padding: "10px 14px", color: "#64748b" }}>#{item.id}</td>
634
- <td style={{ padding: "10px 14px", fontWeight: 600, color: "#0f172a", fontFamily: "monospace" }}>{item.pattern}</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>
864
+ <td style={{ padding: "10px 14px", fontWeight: 600, color: "#0f172a" }}>
865
+ {/* パターン許可とグループ許可では正準値が違う。グループはアドレスではなく
866
+ グループそのものが対象なので、名前とキーを見せる */}
867
+ {item.type === "group" ? (
868
+ <span data-testid={`allowlist-group-${item.type}-${item.id}`}>
869
+ {item.groupName}
870
+ <span style={{ marginLeft: "6px", fontFamily: "monospace", fontSize: "0.78rem", color: "#64748b" }}>{item.groupKey}</span>
871
+ </span>
872
+ ) : (
873
+ <span style={{ fontFamily: "monospace" }}>{item.pattern}</span>
874
+ )}
875
+ </td>
635
876
  <td style={{ padding: "10px 14px" }}>
636
- <span style={{ fontSize: "0.75rem", padding: "2px 8px", borderRadius: "10px", backgroundColor: item.type === "email" ? "#e0f2fe" : "#fef3c7", color: item.type === "email" ? "#0369a1" : "#b45309", fontWeight: 600 }}>
637
- {item.type === "email" ? labels.allowlistView.emailTypeShort : labels.allowlistView.domainTypeShort}
638
- </span>
877
+ <span style={{ fontSize: "0.75rem", padding: "2px 8px", borderRadius: "10px", ...allowlistBadgeStyle(item.type), fontWeight: 600 }}>{allowlistTypeLabel(item.type, labels)}</span>
878
+ </td>
879
+ <td style={{ padding: "10px 14px", color: "#475569" }}>
880
+ {item.description || labels.common.none}
881
+ {/* 配備全体が所有する行は、どのテナントの管理者でも編集できない */}
882
+ {item.owner.kind === "deployment" && (
883
+ <span data-testid={`allowlist-owner-${item.type}-${item.id}`} style={{ marginLeft: "8px", fontSize: "0.72rem", color: "#475569" }}>
884
+ {labels.allowlistView.deploymentOwner}
885
+ </span>
886
+ )}
639
887
  </td>
640
- <td style={{ padding: "10px 14px", color: "#475569" }}>{item.description || "-"}</td>
641
888
  <td style={{ padding: "10px 14px", textAlign: "right" }}>
642
- {/* 変更不能な供給元 (環境変数など) は削除ボタンを出さない */}
889
+ {/* 変更不能な供給元 (環境変数など) は削除ボタンを出さない。無効化ではなく
890
+ 非描画にするのは、押せないボタンが押せる操作と見分けられないため */}
643
891
  {item.readOnly ? (
644
- <span data-testid={`readonly-allowlist-${item.id}`} style={{ fontSize: "0.75rem", color: "#94a3b8" }}>
645
- {labels.allowlistView.readOnly}
892
+ <span data-testid={`readonly-allowlist-${item.type}-${item.id}`} style={{ fontSize: "0.75rem", color: "#475569" }}>
893
+ {labels.common.readOnly}
646
894
  </span>
647
895
  ) : (
648
896
  <button
649
897
  type="button"
650
- onClick={() => handleRemoveEntry(item.id)}
651
- 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}`}
652
908
  style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #fee2e2", backgroundColor: "#fff5f5", color: "#b91c1c", fontSize: "0.78rem", cursor: "pointer" }}>
653
- 削除
909
+ {labels.common.delete}
654
910
  </button>
655
911
  )}
656
912
  </td>
@@ -658,7 +914,7 @@ export function AllowlistView() {
658
914
  ))}
659
915
  {data.allowlist.length === 0 && (
660
916
  <tr>
661
- <td colSpan={5} style={{ padding: "2rem", textAlign: "center", color: "#94a3b8" }}>
917
+ <td colSpan={5} style={{ padding: "2rem", textAlign: "center", color: "#475569" }}>
662
918
  {labels.allowlistView.empty}
663
919
  </td>
664
920
  </tr>
@@ -670,11 +926,253 @@ export function AllowlistView() {
670
926
  )
671
927
  }
672
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
+
673
1170
  const SEGMENT_VIEWS: Record<string, () => React.ReactElement> = {
674
1171
  users: UsersView,
675
1172
  groups: GroupsView,
676
1173
  group_members: GroupMembersView,
677
1174
  allowlist: AllowlistView,
1175
+ directory: DirectoryView,
678
1176
  }
679
1177
 
680
1178
  export type AuthUserAdminAppViewProps = UserAdminShellProps