@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.
- package/dist/admin.d.mts +4 -8
- package/dist/admin.d.ts +4 -8
- package/dist/admin.js +1 -1
- package/dist/admin.js.map +1 -1
- package/dist/admin.mjs +1 -1
- package/dist/admin.mjs.map +1 -1
- package/package.json +1 -1
- package/src/admin/format.ts +21 -5
- package/src/admin/labels.ts +10 -18
- package/src/admin/server.ts +40 -6
- package/src/admin/ui.tsx +9 -2
- package/src/admin/views.tsx +127 -65
package/src/admin/views.tsx
CHANGED
|
@@ -27,8 +27,8 @@ export type UserAdminLoaderData = {
|
|
|
27
27
|
* ロックアウト理由・重複パターン・入力不正などの文言は action が `{ok:false,error}` で返す。
|
|
28
28
|
* 表示しなければ操作が黙って失敗したように見えるため、各ビューの先頭に必ず置く。
|
|
29
29
|
*/
|
|
30
|
-
function ActionError({
|
|
31
|
-
const result =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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, () =>
|
|
140
|
-
const editUserRef = useModalBehavior(editingUser !== 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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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 }, () =>
|
|
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
|
-
{!
|
|
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
|
|
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={() =>
|
|
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
|
|
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={() =>
|
|
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
|
|
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) =>
|
|
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) =>
|
|
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={() =>
|
|
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 [
|
|
369
|
-
const
|
|
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, () =>
|
|
376
|
-
const editGroupRef = useModalBehavior(editingGroup !== 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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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 ?? "" }, () =>
|
|
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
|
-
{!
|
|
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
|
|
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={() =>
|
|
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
|
|
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={() =>
|
|
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
|
|
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) =>
|
|
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) =>
|
|
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={() =>
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
784
|
-
|
|
785
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
964
|
-
|
|
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
|
|
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={() =>
|
|
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
|
-
|
|
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}>
|