@aiquants/auth-react-router 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,8 +27,8 @@ export type UserAdminLoaderData = {
27
27
  * ロックアウト理由・重複パターン・入力不正などの文言は action が `{ok:false,error}` で返す。
28
28
  * 表示しなければ操作が黙って失敗したように見えるため、各ビューの先頭に必ず置く。
29
29
  */
30
- function ActionError({ fetcher, id }: { fetcher: { data?: unknown }; id?: string }) {
31
- const result = fetcher.data as { ok?: boolean; error?: string } | undefined
30
+ function ActionError({ result: raw, id }: { result: unknown; id?: string }) {
31
+ const result = raw as { ok?: boolean; error?: string } | undefined
32
32
  if (result?.ok !== false || !result.error) return null
33
33
  return (
34
34
  // グリッド配下でも行を跨ぐ。空のときは描かないので、余分な行も生まれない
@@ -52,6 +52,10 @@ function ActionError({ fetcher, id }: { fetcher: { data?: unknown }; id?: string
52
52
  */
53
53
  function useSubmitThen(fetcher: ReturnType<typeof useFetcher>) {
54
54
  const pending = useRef<(() => void) | null>(null)
55
+ // 破棄した結果の世代。`fetcher.data` は宣言的に消せないため、こちらで無効化する
56
+ const [discarded, setDiscarded] = useState<unknown>(undefined)
57
+ const result = fetcher.data === discarded ? undefined : fetcher.data
58
+
55
59
  useEffect(() => {
56
60
  if (fetcher.state !== "idle" || fetcher.data === undefined) return
57
61
  const onSuccess = pending.current
@@ -63,10 +67,18 @@ function useSubmitThen(fetcher: ReturnType<typeof useFetcher>) {
63
67
  if ((fetcher.data as { ok?: boolean }).ok === true) onSuccess()
64
68
  }, [fetcher.state, fetcher.data])
65
69
 
66
- return (payload: Record<string, string>, onSuccess?: () => void) => {
70
+ const submit = (payload: Record<string, string>, onSuccess?: () => void) => {
67
71
  pending.current = onSuccess ?? null
72
+ setDiscarded(undefined)
68
73
  fetcher.submit(payload, { method: "post" })
69
74
  }
75
+ /**
76
+ * Forgets the last result so an unrelated failure cannot describe a freshly opened dialog.
77
+ * 直前の結果を忘れる処理 (無関係な失敗が、開いたばかりのダイアログの説明文にならないように)。
78
+ */
79
+ const resetResult = () => setDiscarded(fetcher.data)
80
+
81
+ return { submit, resetResult, result }
70
82
  }
71
83
 
72
84
  /**
@@ -80,7 +92,7 @@ function useSubmitThen(fetcher: ReturnType<typeof useFetcher>) {
80
92
  * @param onClose Invoked when Escape is pressed. Escape が押されたときに呼ばれる処理。
81
93
  * @returns Ref to attach to the dialog panel. ダイアログ本体へ付ける ref。
82
94
  */
83
- function useModalBehavior(isOpen: boolean, onClose: () => void) {
95
+ function useModalBehavior(isOpen: boolean, onClose: () => void, openerRef?: React.RefObject<HTMLElement | null>) {
84
96
  const ref = useRef<HTMLFormElement>(null)
85
97
  // 再描画のたびに実行されると入力中に焦点が先頭へ飛ぶため、依存は開閉だけにする
86
98
  const closeRef = useRef(onClose)
@@ -89,12 +101,17 @@ function useModalBehavior(isOpen: boolean, onClose: () => void) {
89
101
  useEffect(() => {
90
102
  if (!isOpen) return
91
103
  const panel = ref.current
92
- const opener = document.activeElement as HTMLElement | null
104
+ // `activeElement` を見るだけでは戻し先にならない。押下で焦点が乗らないブラウザ
105
+ // (Safari / macOS の Firefox) では body へ戻ってしまう。開いた要素を明示的に覚える
106
+ const opener = openerRef?.current ?? (document.activeElement as HTMLElement | null)
93
107
  const focusables = () => Array.from(panel?.querySelectorAll<HTMLElement>('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])') ?? [])
94
108
  focusables()[0]?.focus()
95
109
 
96
110
  const onKeyDown = (event: KeyboardEvent) => {
97
111
  if (event.key === "Escape") {
112
+ // 変換中の Escape は「候補を取り消す」であって「閉じる」ではない。ここで
113
+ // 閉じると、編集中の下書きは状態ごと捨てられ、打ち直しになる
114
+ if (event.isComposing || event.keyCode === 229) return
98
115
  event.preventDefault()
99
116
  closeRef.current()
100
117
  return
@@ -119,7 +136,7 @@ function useModalBehavior(isOpen: boolean, onClose: () => void) {
119
136
  document.removeEventListener("keydown", onKeyDown)
120
137
  opener?.focus?.()
121
138
  }
122
- }, [isOpen])
139
+ }, [isOpen, openerRef])
123
140
 
124
141
  return ref
125
142
  }
@@ -127,17 +144,27 @@ function useModalBehavior(isOpen: boolean, onClose: () => void) {
127
144
  export function UsersView() {
128
145
  const data = useLoaderData() as UserAdminLoaderData
129
146
  const fetcher = useFetcher()
130
- const submitThen = useSubmitThen(fetcher)
147
+ const { submit: submitThen, resetResult, result: actionResult } = useSubmitThen(fetcher)
131
148
  const labels = data.labels ?? defaultUserAdminLabels
132
149
 
133
150
  const [searchQuery, setSearchQuery] = useState("")
134
- const [editingUser, setEditingUser] = useState<AuthUser | null>(null)
135
- const [isCreateOpen, setIsCreateOpen] = useState(false)
151
+ // 2 つの独立した状態にすると「両方開いている」状態が表現でき、エラー欄が 2 つ描かれる。
152
+ // 排他であることを型で持つ
153
+ const [modal, setModal] = useState<{ kind: "create" } | { kind: "edit"; user: AuthUser } | null>(null)
154
+ const editingUser = modal?.kind === "edit" ? modal.user : null
155
+ const isCreateOpen = modal?.kind === "create"
156
+ const openerRef = useRef<HTMLElement | null>(null)
157
+ /** ダイアログを開く。直前の操作の結果を持ち越さない (別の操作の拒否が説明文になる)。 */
158
+ const openModal = (next: { kind: "create" } | { kind: "edit"; user: AuthUser }, event: React.MouseEvent<HTMLElement>) => {
159
+ openerRef.current = event.currentTarget
160
+ resetResult()
161
+ setModal(next)
162
+ }
136
163
 
137
164
  const [newDisplayName, setNewDisplayName] = useState("")
138
165
  const [newEmail, setNewEmail] = useState("")
139
- const createUserRef = useModalBehavior(isCreateOpen, () => setIsCreateOpen(false))
140
- const editUserRef = useModalBehavior(editingUser !== null, () => setEditingUser(null))
166
+ const createUserRef = useModalBehavior(isCreateOpen, () => setModal(null), openerRef)
167
+ const editUserRef = useModalBehavior(editingUser !== null, () => setModal(null), openerRef)
141
168
 
142
169
  const busy = fetcher.state !== "idle"
143
170
  const filteredUsers = data.users.filter((u) => u.displayName.toLowerCase().includes(searchQuery.toLowerCase()) || u.email.toLowerCase().includes(searchQuery.toLowerCase()))
@@ -151,24 +178,27 @@ export function UsersView() {
151
178
  const handleCreateUser = (e: React.FormEvent) => {
152
179
  e.preventDefault()
153
180
  if (!(newDisplayName && newEmail)) return
154
- submitThen({ intent: "createUser", displayName: newDisplayName, email: newEmail }, () => {
155
- setNewDisplayName("")
156
- setNewEmail("")
157
- setIsCreateOpen(false)
181
+ // 送信した値と今の値が違うなら、往復の間に打ち込まれたものである。消してはならない
182
+ const sentName = newDisplayName
183
+ const sentEmail = newEmail
184
+ submitThen({ intent: "createUser", displayName: sentName, email: sentEmail }, () => {
185
+ setNewDisplayName((current) => (current === sentName ? "" : current))
186
+ setNewEmail((current) => (current === sentEmail ? "" : current))
187
+ setModal(null)
158
188
  })
159
189
  }
160
190
 
161
191
  const handleUpdateUser = (e: React.FormEvent) => {
162
192
  e.preventDefault()
163
193
  if (!editingUser) return
164
- submitThen({ intent: "updateUser", userId: String(editingUser.id), displayName: editingUser.displayName, email: editingUser.email }, () => setEditingUser(null))
194
+ submitThen({ intent: "updateUser", userId: String(editingUser.id), displayName: editingUser.displayName, email: editingUser.email }, () => setModal(null))
165
195
  }
166
196
 
167
197
  return (
168
198
  <section data-testid="users-view-section" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
169
199
  <h2 style={heading}>{labels.usersView.title}</h2>
170
200
  {/* モーダルが開いている間はモーダル内に描く。両方描くと同じ hook が 2 つになる */}
171
- {!(isCreateOpen || editingUser) && <ActionError fetcher={fetcher} />}
201
+ {!modal && <ActionError result={actionResult} />}
172
202
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", backgroundColor: "#ffffff", padding: "1rem", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
173
203
  <input
174
204
  type="text"
@@ -179,7 +209,11 @@ export function UsersView() {
179
209
  data-testid="users-search-input"
180
210
  style={{ padding: "6px 12px", borderRadius: "6px", border: "1px solid #cbd5e1", fontSize: "0.85rem", width: "260px" }}
181
211
  />
182
- <button type="button" onClick={() => setIsCreateOpen(true)} data-testid="users-add-button" style={{ padding: "6px 14px", borderRadius: "6px", backgroundColor: "#0284c7", color: "#ffffff", border: "none", fontSize: "0.85rem", fontWeight: 600, cursor: "pointer" }}>
212
+ <button
213
+ type="button"
214
+ onClick={(event) => openModal({ kind: "create" }, event)}
215
+ data-testid="users-add-button"
216
+ style={{ padding: "6px 14px", borderRadius: "6px", backgroundColor: "#0284c7", color: "#ffffff", border: "none", fontSize: "0.85rem", fontWeight: 600, cursor: "pointer" }}>
183
217
  {labels.usersView.addUser}
184
218
  </button>
185
219
  </div>
@@ -209,7 +243,7 @@ export function UsersView() {
209
243
  <td style={{ padding: "10px 14px", textAlign: "right" }}>
210
244
  <button
211
245
  type="button"
212
- onClick={() => setEditingUser(u)}
246
+ onClick={(event) => openModal({ kind: "edit", user: u }, event)}
213
247
  aria-label={fill(labels.usersView.editUserAria, { user: u.displayName })}
214
248
  data-testid={`edit-user-btn-${u.id}`}
215
249
  style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", fontSize: "0.78rem", cursor: "pointer", marginRight: "6px" }}>
@@ -254,7 +288,7 @@ export function UsersView() {
254
288
  <h3 id="create-user-title" style={{ margin: "0 0 1rem 0", fontSize: "1.1rem" }}>
255
289
  {labels.usersView.addUser}
256
290
  </h3>
257
- <ActionError fetcher={fetcher} id="create-user-error" />
291
+ <ActionError result={actionResult} id="create-user-error" />
258
292
  <div style={{ marginBottom: "1rem" }}>
259
293
  <label htmlFor="create-user-display-name-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
260
294
  {labels.usersView.displayName}
@@ -284,11 +318,11 @@ export function UsersView() {
284
318
  />
285
319
  </div>
286
320
  <div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
287
- <button type="button" onClick={() => setIsCreateOpen(false)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
288
- キャンセル
321
+ <button type="button" onClick={() => setModal(null)} data-testid="cancel-create-user" style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
322
+ {labels.common.cancel}
289
323
  </button>
290
324
  <button type="submit" data-testid="submit-create-user" disabled={busy} aria-busy={busy} style={{ padding: "6px 12px", borderRadius: "4px", backgroundColor: "#0284c7", color: "#ffffff", border: "none", cursor: "pointer" }}>
291
- 作成
325
+ {labels.common.create}
292
326
  </button>
293
327
  </div>
294
328
  </form>
@@ -310,7 +344,7 @@ export function UsersView() {
310
344
  <h3 id="edit-user-title" style={{ margin: "0 0 1rem 0", fontSize: "1.1rem" }}>
311
345
  {labels.usersView.editUser}
312
346
  </h3>
313
- <ActionError fetcher={fetcher} id="edit-user-error" />
347
+ <ActionError result={actionResult} id="edit-user-error" />
314
348
  <div style={{ marginBottom: "1rem" }}>
315
349
  <label htmlFor="edit-user-display-name-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
316
350
  {labels.usersView.displayName}
@@ -319,7 +353,7 @@ export function UsersView() {
319
353
  id="edit-user-display-name-input"
320
354
  type="text"
321
355
  value={editingUser.displayName}
322
- onChange={(e) => setEditingUser({ ...editingUser, displayName: e.target.value })}
356
+ onChange={(e) => setModal({ kind: "edit", user: { ...editingUser, displayName: e.target.value } })}
323
357
  required
324
358
  data-testid="edit-user-display-name"
325
359
  style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box" }}
@@ -333,14 +367,14 @@ export function UsersView() {
333
367
  id="edit-user-email-input"
334
368
  type="email"
335
369
  value={editingUser.email}
336
- onChange={(e) => setEditingUser({ ...editingUser, email: e.target.value })}
370
+ onChange={(e) => setModal({ kind: "edit", user: { ...editingUser, email: e.target.value } })}
337
371
  required
338
372
  data-testid="edit-user-email"
339
373
  style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box" }}
340
374
  />
341
375
  </div>
342
376
  <div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
343
- <button type="button" onClick={() => setEditingUser(null)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
377
+ <button type="button" onClick={() => setModal(null)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
344
378
  {labels.common.cancel}
345
379
  </button>
346
380
  <button type="submit" data-testid="submit-edit-user" disabled={busy} aria-busy={busy} style={{ padding: "6px 12px", borderRadius: "4px", backgroundColor: "#0284c7", color: "#ffffff", border: "none", cursor: "pointer" }}>
@@ -361,37 +395,47 @@ export function UsersView() {
361
395
  export function GroupsView() {
362
396
  const data = useLoaderData() as UserAdminLoaderData
363
397
  const fetcher = useFetcher()
364
- const submitThen = useSubmitThen(fetcher)
398
+ const { submit: submitThen, resetResult, result: actionResult } = useSubmitThen(fetcher)
365
399
  const labels = data.labels ?? defaultUserAdminLabels
366
400
 
367
401
  const [searchQuery, setSearchQuery] = useState("")
368
- const [isCreateOpen, setIsCreateOpen] = useState(false)
369
- const [editingGroup, setEditingGroup] = useState<AuthGroup | null>(null)
402
+ const [modal, setModal] = useState<{ kind: "create" } | { kind: "edit"; group: AuthGroup } | null>(null)
403
+ const editingGroup = modal?.kind === "edit" ? modal.group : null
404
+ const isCreateOpen = modal?.kind === "create"
405
+ const openerRef = useRef<HTMLElement | null>(null)
406
+ /** ダイアログを開く。直前の操作の結果を持ち越さない (別の操作の拒否が説明文になる)。 */
407
+ const openModal = (next: { kind: "create" } | { kind: "edit"; group: AuthGroup }, event: React.MouseEvent<HTMLElement>) => {
408
+ openerRef.current = event.currentTarget
409
+ resetResult()
410
+ setModal(next)
411
+ }
370
412
 
371
413
  const busy = fetcher.state !== "idle"
372
414
  const [newGroupKey, setNewGroupKey] = useState("")
373
415
  const [newName, setNewName] = useState("")
374
416
  const [newDescription, setNewDescription] = useState("")
375
- const createGroupRef = useModalBehavior(isCreateOpen, () => setIsCreateOpen(false))
376
- const editGroupRef = useModalBehavior(editingGroup !== null, () => setEditingGroup(null))
417
+ const createGroupRef = useModalBehavior(isCreateOpen, () => setModal(null), openerRef)
418
+ const editGroupRef = useModalBehavior(editingGroup !== null, () => setModal(null), openerRef)
377
419
 
378
420
  const filteredGroups = data.groups.filter((g) => g.name.toLowerCase().includes(searchQuery.toLowerCase()) || g.groupKey.toLowerCase().includes(searchQuery.toLowerCase()))
379
421
 
380
422
  const handleCreateGroup = (e: React.FormEvent) => {
381
423
  e.preventDefault()
382
424
  if (!(newGroupKey && newName)) return
383
- submitThen({ intent: "createGroup", groupKey: newGroupKey, name: newName, description: newDescription }, () => {
384
- setNewGroupKey("")
385
- setNewName("")
386
- setNewDescription("")
387
- setIsCreateOpen(false)
425
+ // 送信した値と今の値が違うなら、往復の間に打ち込まれたものである。消してはならない
426
+ const sent = { key: newGroupKey, name: newName, description: newDescription }
427
+ submitThen({ intent: "createGroup", groupKey: sent.key, name: sent.name, description: sent.description }, () => {
428
+ setNewGroupKey((current) => (current === sent.key ? "" : current))
429
+ setNewName((current) => (current === sent.name ? "" : current))
430
+ setNewDescription((current) => (current === sent.description ? "" : current))
431
+ setModal(null)
388
432
  })
389
433
  }
390
434
 
391
435
  const handleUpdateGroup = (e: React.FormEvent) => {
392
436
  e.preventDefault()
393
437
  if (!editingGroup) return
394
- submitThen({ intent: "updateGroup", groupId: String(editingGroup.id), name: editingGroup.name, description: editingGroup.description ?? "" }, () => setEditingGroup(null))
438
+ submitThen({ intent: "updateGroup", groupId: String(editingGroup.id), name: editingGroup.name, description: editingGroup.description ?? "" }, () => setModal(null))
395
439
  }
396
440
 
397
441
  const handleDeleteGroup = (groupId: number) => {
@@ -402,7 +446,7 @@ export function GroupsView() {
402
446
  return (
403
447
  <section data-testid="groups-view-section" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
404
448
  <h2 style={heading}>{labels.groupsView.title}</h2>
405
- {!(isCreateOpen || editingGroup) && <ActionError fetcher={fetcher} />}
449
+ {!modal && <ActionError result={actionResult} />}
406
450
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", backgroundColor: "#ffffff", padding: "1rem", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
407
451
  <input
408
452
  type="text"
@@ -413,7 +457,11 @@ export function GroupsView() {
413
457
  data-testid="groups-search-input"
414
458
  style={{ padding: "6px 12px", borderRadius: "6px", border: "1px solid #cbd5e1", fontSize: "0.85rem", width: "260px" }}
415
459
  />
416
- <button type="button" onClick={() => setIsCreateOpen(true)} data-testid="groups-add-button" style={{ padding: "6px 14px", borderRadius: "6px", backgroundColor: "#4338ca", color: "#ffffff", border: "none", fontSize: "0.85rem", fontWeight: 600, cursor: "pointer" }}>
460
+ <button
461
+ type="button"
462
+ onClick={(event) => openModal({ kind: "create" }, event)}
463
+ data-testid="groups-add-button"
464
+ style={{ padding: "6px 14px", borderRadius: "6px", backgroundColor: "#4338ca", color: "#ffffff", border: "none", fontSize: "0.85rem", fontWeight: 600, cursor: "pointer" }}>
417
465
  {labels.groupsView.addGroup}
418
466
  </button>
419
467
  </div>
@@ -460,7 +508,10 @@ export function GroupsView() {
460
508
  <div>
461
509
  <button
462
510
  type="button"
463
- onClick={() => setEditingGroup(g)}
511
+ onClick={(event) => openModal({ kind: "edit", group: g }, event)}
512
+ disabled={busy}
513
+ aria-busy={busy}
514
+ aria-label={fill(labels.groupsView.editGroupAria, { group: g.name })}
464
515
  data-testid={`edit-group-btn-${g.id}`}
465
516
  style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", fontSize: "0.75rem", cursor: "pointer", marginRight: "4px" }}>
466
517
  {labels.common.edit}
@@ -468,6 +519,9 @@ export function GroupsView() {
468
519
  <button
469
520
  type="button"
470
521
  onClick={() => handleDeleteGroup(g.id)}
522
+ disabled={busy}
523
+ aria-busy={busy}
524
+ aria-label={fill(labels.groupsView.deleteGroupAria, { group: g.name })}
471
525
  data-testid={`delete-group-btn-${g.id}`}
472
526
  style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #fee2e2", backgroundColor: "#fff5f5", color: "#b91c1c", fontSize: "0.75rem", cursor: "pointer" }}>
473
527
  {labels.common.delete}
@@ -500,7 +554,7 @@ export function GroupsView() {
500
554
  <h3 id="create-group-title" style={{ margin: "0 0 1rem 0", fontSize: "1.1rem" }}>
501
555
  {labels.groupsView.addGroup}
502
556
  </h3>
503
- <ActionError fetcher={fetcher} id="create-group-error" />
557
+ <ActionError result={actionResult} id="create-group-error" />
504
558
  <div style={{ marginBottom: "1rem" }}>
505
559
  <label htmlFor="create-group-key-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
506
560
  {labels.groupsView.groupKey}
@@ -544,11 +598,11 @@ export function GroupsView() {
544
598
  />
545
599
  </div>
546
600
  <div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
547
- <button type="button" onClick={() => setIsCreateOpen(false)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
548
- キャンセル
601
+ <button type="button" onClick={() => setModal(null)} data-testid="cancel-create-group" style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
602
+ {labels.common.cancel}
549
603
  </button>
550
604
  <button type="submit" data-testid="submit-create-group" disabled={busy} aria-busy={busy} style={{ padding: "6px 12px", borderRadius: "4px", backgroundColor: "#4338ca", color: "#ffffff", border: "none", cursor: "pointer" }}>
551
- 作成
605
+ {labels.common.create}
552
606
  </button>
553
607
  </div>
554
608
  </form>
@@ -570,7 +624,7 @@ export function GroupsView() {
570
624
  <h3 id="edit-group-title" style={{ margin: "0 0 1rem 0", fontSize: "1.1rem" }}>
571
625
  {labels.groupsView.editGroup}
572
626
  </h3>
573
- <ActionError fetcher={fetcher} id="edit-group-error" />
627
+ <ActionError result={actionResult} id="edit-group-error" />
574
628
  <div style={{ marginBottom: "1rem" }}>
575
629
  <label htmlFor="edit-group-name-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
576
630
  {labels.groupsView.name}
@@ -579,7 +633,7 @@ export function GroupsView() {
579
633
  id="edit-group-name-input"
580
634
  type="text"
581
635
  value={editingGroup.name}
582
- onChange={(e) => setEditingGroup({ ...editingGroup, name: e.target.value })}
636
+ onChange={(e) => setModal({ kind: "edit", group: { ...editingGroup, name: e.target.value } })}
583
637
  required
584
638
  data-testid="edit-group-name"
585
639
  style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box" }}
@@ -592,13 +646,13 @@ export function GroupsView() {
592
646
  <textarea
593
647
  id="edit-group-desc-input"
594
648
  value={editingGroup.description ?? ""}
595
- onChange={(e) => setEditingGroup({ ...editingGroup, description: e.target.value })}
649
+ onChange={(e) => setModal({ kind: "edit", group: { ...editingGroup, description: e.target.value } })}
596
650
  data-testid="edit-group-description"
597
651
  style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box", height: "60px" }}
598
652
  />
599
653
  </div>
600
654
  <div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
601
- <button type="button" onClick={() => setEditingGroup(null)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
655
+ <button type="button" onClick={() => setModal(null)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
602
656
  {labels.common.cancel}
603
657
  </button>
604
658
  <button type="submit" data-testid="submit-edit-group" disabled={busy} aria-busy={busy} style={{ padding: "6px 12px", borderRadius: "4px", backgroundColor: "#4338ca", color: "#ffffff", border: "none", cursor: "pointer" }}>
@@ -619,13 +673,14 @@ export function GroupsView() {
619
673
  export function GroupMembersView() {
620
674
  const data = useLoaderData() as UserAdminLoaderData
621
675
  const fetcher = useFetcher()
622
- const submitThen = useSubmitThen(fetcher)
676
+ const { submit: submitThen, result: actionResult } = useSubmitThen(fetcher)
623
677
  const busy = fetcher.state !== "idle"
624
678
  const labels = data.labels ?? defaultUserAdminLabels
625
679
 
626
680
  const [selectedGroupId, setSelectedGroupId] = useState<number>(data.groups[0]?.id ?? 0)
627
681
 
628
- const currentGroup = data.groups.find((g) => g.id === selectedGroupId)
682
+ // 再検証で対象が消えることがある。消えた ID を握り続けると空の面になる
683
+ const currentGroup = data.groups.find((g) => g.id === selectedGroupId) ?? data.groups[0]
629
684
  const currentMemberUserIds = new Set(data.groupMembers.filter((m) => m.groupId === selectedGroupId).map((m) => m.userId))
630
685
 
631
686
  const handleToggleMember = (userId: number, isMember: boolean) => {
@@ -639,7 +694,7 @@ export function GroupMembersView() {
639
694
  return (
640
695
  <section data-testid="group-members-view-section" style={{ display: "grid", gridTemplateColumns: "240px 1fr", gap: "1.2rem" }}>
641
696
  <h2 style={{ ...heading, gridColumn: "1 / -1" }}>{labels.groupMembersView.title}</h2>
642
- <ActionError fetcher={fetcher} />
697
+ <ActionError result={actionResult} />
643
698
  <div style={{ backgroundColor: "#ffffff", padding: "1rem", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
644
699
  <h4 style={{ margin: "0 0 0.8rem 0", fontSize: "0.9rem", color: "#475569" }}>{labels.groupMembersView.selectGroup}</h4>
645
700
  <div style={{ display: "flex", flexDirection: "column", gap: "0.4rem" }}>
@@ -771,7 +826,7 @@ export function AllowlistView() {
771
826
  const fetcher = useFetcher()
772
827
  const labels = data.labels ?? defaultUserAdminLabels
773
828
 
774
- const submitThen = useSubmitThen(fetcher)
829
+ const { submit: submitThen, result: actionResult } = useSubmitThen(fetcher)
775
830
  const busy = fetcher.state !== "idle"
776
831
  const [pattern, setPattern] = useState("")
777
832
  const [type, setType] = useState<"email" | "domain">("email")
@@ -780,9 +835,10 @@ export function AllowlistView() {
780
835
  const handleAddEntry = (e: React.FormEvent) => {
781
836
  e.preventDefault()
782
837
  if (!pattern) return
783
- submitThen({ intent: "addAllowlistEntry", pattern, type, description }, () => {
784
- setPattern("")
785
- setDescription("")
838
+ const sent = { pattern, description }
839
+ submitThen({ intent: "addAllowlistEntry", pattern: sent.pattern, type, description: sent.description }, () => {
840
+ setPattern((current) => (current === sent.pattern ? "" : current))
841
+ setDescription((current) => (current === sent.description ? "" : current))
786
842
  })
787
843
  }
788
844
 
@@ -798,7 +854,7 @@ export function AllowlistView() {
798
854
  return (
799
855
  <section data-testid="allowlist-view-section" style={{ display: "flex", flexDirection: "column", gap: "1.2rem" }}>
800
856
  <h2 style={heading}>{labels.allowlistView.title}</h2>
801
- <ActionError fetcher={fetcher} />
857
+ <ActionError result={actionResult} />
802
858
  <form onSubmit={handleAddEntry} style={{ backgroundColor: "#ffffff", padding: "1.2rem", borderRadius: "8px", border: "1px solid #e2e8f0", display: "flex", flexWrap: "wrap", gap: "0.8rem", alignItems: "flex-end" }}>
803
859
  <div style={{ flex: 2, minWidth: "220px" }}>
804
860
  <label htmlFor="allowlist-pattern-input-field" style={{ display: "block", fontSize: "0.78rem", color: "#475569", marginBottom: "4px" }}>
@@ -937,7 +993,7 @@ export function AllowlistView() {
937
993
  export function DirectoryView() {
938
994
  const data = useLoaderData() as UserAdminLoaderData
939
995
  const fetcher = useFetcher()
940
- const submitThen = useSubmitThen(fetcher)
996
+ const { submit: submitThen, result: actionResult } = useSubmitThen(fetcher)
941
997
  const labels = data.labels ?? defaultUserAdminLabels
942
998
  const L = labels.directoryView
943
999
 
@@ -952,7 +1008,8 @@ export function DirectoryView() {
952
1008
  const busy = fetcher.state !== "idle"
953
1009
  // 「要求しました」を出す対象。押しても何も変わらないと、何度も押される
954
1010
  const [queuedGroupId, setQueuedGroupId] = useState<number | null>(null)
955
- const canLink = data.directoryStatus.isEnabled
1011
+ // 供給元が判らないまま欄を出すと、送信は必ずサーバー側の内部文言で失敗する
1012
+ const canLink = data.directoryStatus.isEnabled && data.directoryStatus.provider !== ""
956
1013
 
957
1014
  /** 連携を作る。上流キーは運用者が知っている値 (グループアドレス) で受け取る。 */
958
1015
  const handleLink = (e: React.FormEvent) => {
@@ -960,8 +1017,9 @@ export function DirectoryView() {
960
1017
  if (!(groupId && externalKey)) return
961
1018
  // 供給元は配備の設定から来る。ここに特定の値を書くと、別の上流を使う配備で作った
962
1019
  // リンクが同期側で恒久的に拒否される
963
- submitThen({ intent: "linkDirectoryGroup", groupId, provider: data.directoryStatus.provider, externalKey, membershipMode }, () => {
964
- setExternalKey("")
1020
+ const sentKey = externalKey
1021
+ submitThen({ intent: "linkDirectoryGroup", groupId, provider: data.directoryStatus.provider, externalKey: sentKey, membershipMode }, () => {
1022
+ setExternalKey((current) => (current === sentKey ? "" : current))
965
1023
  // 連携済みになった行は候補から消える。選択を残すと、表示は空欄なのに前の
966
1024
  // グループへ送信される
967
1025
  setGroupId("")
@@ -971,7 +1029,7 @@ export function DirectoryView() {
971
1029
  return (
972
1030
  <section data-testid="directory-view-section" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
973
1031
  <h2 style={heading}>{L.title}</h2>
974
- <ActionError fetcher={fetcher} />
1032
+ <ActionError result={actionResult} />
975
1033
 
976
1034
  {!canLink && (
977
1035
  <p data-testid="directory-not-wired" style={{ margin: 0, padding: "0.7rem 1rem", borderRadius: "6px", border: "1px solid #e2e8f0", backgroundColor: "#f8fafc", color: "#475569", fontSize: "0.85rem" }}>
@@ -1098,7 +1156,7 @@ export function DirectoryView() {
1098
1156
  data-testid={`directory-sync-now-${g.id}`}
1099
1157
  disabled={busy}
1100
1158
  aria-busy={busy}
1101
- aria-label={fill(L.syncNowAria, { group: g.name })}
1159
+ aria-label={fill(queuedGroupId === g.id ? L.syncQueuedAria : L.syncNowAria, { group: g.name })}
1102
1160
  onClick={() => submitThen({ intent: "requestDirectorySync", groupId: String(g.id) }, () => setQueuedGroupId(g.id))}
1103
1161
  style={smallButton}>
1104
1162
  {queuedGroupId === g.id ? L.syncQueued : L.syncNow}
@@ -1109,7 +1167,10 @@ export function DirectoryView() {
1109
1167
  disabled={busy}
1110
1168
  aria-busy={busy}
1111
1169
  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) })}
1170
+ onClick={() => {
1171
+ setQueuedGroupId(null)
1172
+ submitThen({ intent: "setDirectorySyncEnabled", groupId: String(g.id), isSyncEnabled: String(!link.isSyncEnabled) })
1173
+ }}
1113
1174
  style={smallButton}>
1114
1175
  {link.isSyncEnabled ? L.pause : L.resume}
1115
1176
  </button>
@@ -1184,7 +1245,8 @@ export type AuthUserAdminAppViewProps = UserAdminShellProps
1184
1245
  export function AuthUserAdminAppView({ renderHeader }: AuthUserAdminAppViewProps = {}) {
1185
1246
  const data = useLoaderData() as UserAdminLoaderData | undefined
1186
1247
  const segment = data?.segment ?? "users"
1187
- const View = SEGMENT_VIEWS[segment] ?? UsersView
1248
+ // 素のオブジェクト参照だと `segment="constructor"` が真値を返し、画面が描けなくなる
1249
+ const View = Object.hasOwn(SEGMENT_VIEWS, segment) ? SEGMENT_VIEWS[segment] : UsersView
1188
1250
 
1189
1251
  return (
1190
1252
  <UserAdminShell renderHeader={renderHeader}>