@aiquants/auth-react-router 0.4.0 → 0.5.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.
@@ -0,0 +1,696 @@
1
+ import type { AuthAllowlistEntry, AuthGroup, AuthGroupMember, AuthUser } from "@aiquants/auth-core"
2
+ import type React from "react"
3
+ import { useState } from "react"
4
+ import { useFetcher, useLoaderData } from "react-router"
5
+ import { defaultUserAdminLabels, type UserAdminLabels } from "./labels"
6
+ import { UserAdminShell, type UserAdminShellProps } from "./ui"
7
+
8
+ export type UserAdminLoaderData = {
9
+ segment: string
10
+ users: AuthUser[]
11
+ groups: AuthGroup[]
12
+ groupMembers: AuthGroupMember[]
13
+ allowlist: AuthAllowlistEntry[]
14
+ labels?: UserAdminLabels
15
+ }
16
+
17
+ /**
18
+ * View for managing users (list, search, toggle active, modal edit).
19
+ * ユーザー管理ビュー (一覧・検索・アクティブ切替・ダイアログ編集)。
20
+ */
21
+ /**
22
+ * Renders the error returned by the last action submission, if any.
23
+ * 直近の action 送信が返したエラーを表示する部品。
24
+ *
25
+ * ロックアウト理由・重複パターン・入力不正などの文言は action が `{ok:false,error}` で返す。
26
+ * 表示しなければ操作が黙って失敗したように見えるため、各ビューの先頭に必ず置く。
27
+ */
28
+ function ActionError({ fetcher }: { fetcher: { data?: unknown } }) {
29
+ const result = fetcher.data as { ok?: boolean; error?: string } | undefined
30
+ if (result?.ok !== false || !result.error) return null
31
+ 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" }}>
33
+ {result.error}
34
+ </div>
35
+ )
36
+ }
37
+
38
+ export function UsersView() {
39
+ const data = useLoaderData() as UserAdminLoaderData
40
+ const fetcher = useFetcher()
41
+ const labels = data.labels ?? defaultUserAdminLabels
42
+
43
+ const [searchQuery, setSearchQuery] = useState("")
44
+ const [editingUser, setEditingUser] = useState<AuthUser | null>(null)
45
+ const [isCreateOpen, setIsCreateOpen] = useState(false)
46
+
47
+ const [newDisplayName, setNewDisplayName] = useState("")
48
+ const [newEmail, setNewEmail] = useState("")
49
+
50
+ const filteredUsers = data.users.filter((u) => u.displayName.toLowerCase().includes(searchQuery.toLowerCase()) || u.email.toLowerCase().includes(searchQuery.toLowerCase()))
51
+
52
+ const handleToggleActive = (user: AuthUser) => {
53
+ fetcher.submit({ intent: "toggleUserActive", userId: String(user.id), isActive: String(!user.isActive) }, { method: "post" })
54
+ }
55
+
56
+ const handleCreateUser = (e: React.FormEvent) => {
57
+ e.preventDefault()
58
+ if (!(newDisplayName && newEmail)) return
59
+ fetcher.submit({ intent: "createUser", displayName: newDisplayName, email: newEmail }, { method: "post" })
60
+ setNewDisplayName("")
61
+ setNewEmail("")
62
+ setIsCreateOpen(false)
63
+ }
64
+
65
+ const handleUpdateUser = (e: React.FormEvent) => {
66
+ e.preventDefault()
67
+ if (!editingUser) return
68
+ fetcher.submit({ intent: "updateUser", userId: String(editingUser.id), displayName: editingUser.displayName, email: editingUser.email }, { method: "post" })
69
+ setEditingUser(null)
70
+ }
71
+
72
+ return (
73
+ <section data-testid="users-view-section" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
74
+ <ActionError fetcher={fetcher} />
75
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", backgroundColor: "#ffffff", padding: "1rem", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
76
+ <input
77
+ type="text"
78
+ placeholder={labels.usersView.searchPlaceholder}
79
+ value={searchQuery}
80
+ onChange={(e) => setSearchQuery(e.target.value)}
81
+ data-testid="users-search-input"
82
+ style={{ padding: "6px 12px", borderRadius: "6px", border: "1px solid #cbd5e1", fontSize: "0.85rem", width: "260px" }}
83
+ />
84
+ <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" }}>
85
+ {labels.usersView.addUser}
86
+ </button>
87
+ </div>
88
+
89
+ <div style={{ backgroundColor: "#ffffff", borderRadius: "8px", border: "1px solid #e2e8f0", overflow: "hidden" }}>
90
+ <table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.85rem", textAlign: "left" }}>
91
+ <thead>
92
+ <tr style={{ backgroundColor: "#f8fafc", borderBottom: "1px solid #e2e8f0", color: "#475569" }}>
93
+ <th style={{ padding: "10px 14px" }}>{labels.usersView.id}</th>
94
+ <th style={{ padding: "10px 14px" }}>{labels.usersView.displayName}</th>
95
+ <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>
98
+ </tr>
99
+ </thead>
100
+ <tbody>
101
+ {filteredUsers.map((u) => (
102
+ <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>
104
+ <td style={{ padding: "10px 14px", fontWeight: 600, color: "#0f172a" }}>{u.displayName}</td>
105
+ <td style={{ padding: "10px 14px", color: "#475569" }}>{u.email}</td>
106
+ <td style={{ padding: "10px 14px" }}>
107
+ <span style={{ fontSize: "0.75rem", padding: "2px 8px", borderRadius: "10px", backgroundColor: u.isActive ? "#dcfce7" : "#fee2e2", color: u.isActive ? "#15803d" : "#b91c1c", fontWeight: 600 }}>
108
+ {u.isActive ? labels.usersView.active : labels.usersView.inactive}
109
+ </span>
110
+ </td>
111
+ <td style={{ padding: "10px 14px", textAlign: "right" }}>
112
+ <button
113
+ type="button"
114
+ onClick={() => setEditingUser(u)}
115
+ data-testid={`edit-user-btn-${u.id}`}
116
+ style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", fontSize: "0.78rem", cursor: "pointer", marginRight: "6px" }}>
117
+ {labels.usersView.edit}
118
+ </button>
119
+ <button
120
+ type="button"
121
+ onClick={() => handleToggleActive(u)}
122
+ 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" }}>
124
+ {u.isActive ? labels.usersView.deactivate : labels.usersView.activate}
125
+ </button>
126
+ </td>
127
+ </tr>
128
+ ))}
129
+ {filteredUsers.length === 0 && (
130
+ <tr>
131
+ <td colSpan={5} style={{ padding: "2rem", textAlign: "center", color: "#94a3b8" }}>
132
+ {labels.usersView.empty}
133
+ </td>
134
+ </tr>
135
+ )}
136
+ </tbody>
137
+ </table>
138
+ </div>
139
+
140
+ {/* Create Modal */}
141
+ {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>
145
+ <div style={{ marginBottom: "1rem" }}>
146
+ <label htmlFor="create-user-display-name-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
147
+ {labels.usersView.displayName}
148
+ </label>
149
+ <input
150
+ id="create-user-display-name-input"
151
+ type="text"
152
+ value={newDisplayName}
153
+ onChange={(e) => setNewDisplayName(e.target.value)}
154
+ required
155
+ data-testid="create-user-display-name"
156
+ style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box" }}
157
+ />
158
+ </div>
159
+ <div style={{ marginBottom: "1.2rem" }}>
160
+ <label htmlFor="create-user-email-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
161
+ {labels.usersView.email}
162
+ </label>
163
+ <input
164
+ id="create-user-email-input"
165
+ type="email"
166
+ value={newEmail}
167
+ onChange={(e) => setNewEmail(e.target.value)}
168
+ required
169
+ data-testid="create-user-email"
170
+ style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box" }}
171
+ />
172
+ </div>
173
+ <div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
174
+ <button type="button" onClick={() => setIsCreateOpen(false)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
175
+ キャンセル
176
+ </button>
177
+ <button type="submit" data-testid="submit-create-user" style={{ padding: "6px 12px", borderRadius: "4px", backgroundColor: "#0284c7", color: "#ffffff", border: "none", cursor: "pointer" }}>
178
+ 作成
179
+ </button>
180
+ </div>
181
+ </form>
182
+ </div>
183
+ )}
184
+
185
+ {/* Edit Modal */}
186
+ {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>
190
+ <div style={{ marginBottom: "1rem" }}>
191
+ <label htmlFor="edit-user-display-name-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
192
+ {labels.usersView.displayName}
193
+ </label>
194
+ <input
195
+ id="edit-user-display-name-input"
196
+ type="text"
197
+ value={editingUser.displayName}
198
+ onChange={(e) => setEditingUser({ ...editingUser, displayName: e.target.value })}
199
+ required
200
+ data-testid="edit-user-display-name"
201
+ style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box" }}
202
+ />
203
+ </div>
204
+ <div style={{ marginBottom: "1.2rem" }}>
205
+ <label htmlFor="edit-user-email-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
206
+ {labels.usersView.email}
207
+ </label>
208
+ <input
209
+ id="edit-user-email-input"
210
+ type="email"
211
+ value={editingUser.email}
212
+ onChange={(e) => setEditingUser({ ...editingUser, email: e.target.value })}
213
+ required
214
+ data-testid="edit-user-email"
215
+ style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box" }}
216
+ />
217
+ </div>
218
+ <div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
219
+ <button type="button" onClick={() => setEditingUser(null)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
220
+ {labels.usersView.cancel}
221
+ </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}
224
+ </button>
225
+ </div>
226
+ </form>
227
+ </div>
228
+ )}
229
+ </section>
230
+ )
231
+ }
232
+
233
+ /**
234
+ * View for managing user groups (create, edit, delete).
235
+ * ユーザーグループ管理ビュー (作成・編集・削除)。
236
+ */
237
+ export function GroupsView() {
238
+ const data = useLoaderData() as UserAdminLoaderData
239
+ const fetcher = useFetcher()
240
+ const labels = data.labels ?? defaultUserAdminLabels
241
+
242
+ const [searchQuery, setSearchQuery] = useState("")
243
+ const [isCreateOpen, setIsCreateOpen] = useState(false)
244
+ const [editingGroup, setEditingGroup] = useState<AuthGroup | null>(null)
245
+
246
+ const [newGroupKey, setNewGroupKey] = useState("")
247
+ const [newName, setNewName] = useState("")
248
+ const [newDescription, setNewDescription] = useState("")
249
+
250
+ const filteredGroups = data.groups.filter((g) => g.name.toLowerCase().includes(searchQuery.toLowerCase()) || g.groupKey.toLowerCase().includes(searchQuery.toLowerCase()))
251
+
252
+ const handleCreateGroup = (e: React.FormEvent) => {
253
+ e.preventDefault()
254
+ 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)
260
+ }
261
+
262
+ const handleUpdateGroup = (e: React.FormEvent) => {
263
+ e.preventDefault()
264
+ if (!editingGroup) return
265
+ fetcher.submit({ intent: "updateGroup", groupId: String(editingGroup.id), name: editingGroup.name, description: editingGroup.description ?? "" }, { method: "post" })
266
+ setEditingGroup(null)
267
+ }
268
+
269
+ const handleDeleteGroup = (groupId: number) => {
270
+ if (!window.confirm(labels.groupsView.confirmDelete)) return
271
+ fetcher.submit({ intent: "deleteGroup", groupId: String(groupId) }, { method: "post" })
272
+ }
273
+
274
+ return (
275
+ <section data-testid="groups-view-section" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
276
+ <ActionError fetcher={fetcher} />
277
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", backgroundColor: "#ffffff", padding: "1rem", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
278
+ <input
279
+ type="text"
280
+ placeholder={labels.groupsView.searchPlaceholder}
281
+ value={searchQuery}
282
+ onChange={(e) => setSearchQuery(e.target.value)}
283
+ data-testid="groups-search-input"
284
+ style={{ padding: "6px 12px", borderRadius: "6px", border: "1px solid #cbd5e1", fontSize: "0.85rem", width: "260px" }}
285
+ />
286
+ <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" }}>
287
+ {labels.groupsView.addGroup}
288
+ </button>
289
+ </div>
290
+
291
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: "1rem" }}>
292
+ {filteredGroups.map((g) => {
293
+ const memberCount = data.groupMembers.filter((m) => m.groupId === g.id).length
294
+ 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}`}>
296
+ <div>
297
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "0.5rem" }}>
298
+ <h4 style={{ margin: 0, fontSize: "1rem", color: "#0f172a" }}>{g.name}</h4>
299
+ <span style={{ fontSize: "0.72rem", backgroundColor: "#e0e7ff", color: "#4338ca", padding: "2px 6px", borderRadius: "4px", fontFamily: "monospace" }}>{g.groupKey}</span>
300
+ </div>
301
+ <p style={{ margin: "0 0 0.8rem 0", fontSize: "0.82rem", color: "#64748b", minHeight: "2.4em" }}>{g.description || labels.groupsView.noDescription}</p>
302
+ </div>
303
+ <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> 人
306
+ </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>
323
+ </div>
324
+ </div>
325
+ )
326
+ })}
327
+ </div>
328
+
329
+ {/* Create Group Modal */}
330
+ {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>
334
+ <div style={{ marginBottom: "1rem" }}>
335
+ <label htmlFor="create-group-key-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
336
+ {labels.groupsView.groupKey}
337
+ </label>
338
+ <input
339
+ id="create-group-key-input"
340
+ type="text"
341
+ value={newGroupKey}
342
+ onChange={(e) => setNewGroupKey(e.target.value)}
343
+ placeholder={labels.groupsView.groupKeyPlaceholder}
344
+ required
345
+ data-testid="create-group-key"
346
+ style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box" }}
347
+ />
348
+ </div>
349
+ <div style={{ marginBottom: "1rem" }}>
350
+ <label htmlFor="create-group-name-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
351
+ {labels.groupsView.name}
352
+ </label>
353
+ <input
354
+ id="create-group-name-input"
355
+ type="text"
356
+ value={newName}
357
+ onChange={(e) => setNewName(e.target.value)}
358
+ placeholder={labels.groupsView.namePlaceholder}
359
+ required
360
+ data-testid="create-group-name"
361
+ style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box" }}
362
+ />
363
+ </div>
364
+ <div style={{ marginBottom: "1.2rem" }}>
365
+ <label htmlFor="create-group-desc-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
366
+ {labels.groupsView.description}
367
+ </label>
368
+ <textarea
369
+ id="create-group-desc-input"
370
+ value={newDescription}
371
+ onChange={(e) => setNewDescription(e.target.value)}
372
+ data-testid="create-group-description"
373
+ style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box", height: "60px" }}
374
+ />
375
+ </div>
376
+ <div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
377
+ <button type="button" onClick={() => setIsCreateOpen(false)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
378
+ キャンセル
379
+ </button>
380
+ <button type="submit" data-testid="submit-create-group" style={{ padding: "6px 12px", borderRadius: "4px", backgroundColor: "#4338ca", color: "#ffffff", border: "none", cursor: "pointer" }}>
381
+ 作成
382
+ </button>
383
+ </div>
384
+ </form>
385
+ </div>
386
+ )}
387
+
388
+ {/* Edit Group Modal */}
389
+ {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>
393
+ <div style={{ marginBottom: "1rem" }}>
394
+ <label htmlFor="edit-group-name-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
395
+ {labels.groupsView.name}
396
+ </label>
397
+ <input
398
+ id="edit-group-name-input"
399
+ type="text"
400
+ value={editingGroup.name}
401
+ onChange={(e) => setEditingGroup({ ...editingGroup, name: e.target.value })}
402
+ required
403
+ data-testid="edit-group-name"
404
+ style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box" }}
405
+ />
406
+ </div>
407
+ <div style={{ marginBottom: "1.2rem" }}>
408
+ <label htmlFor="edit-group-desc-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
409
+ {labels.groupsView.description}
410
+ </label>
411
+ <textarea
412
+ id="edit-group-desc-input"
413
+ value={editingGroup.description ?? ""}
414
+ onChange={(e) => setEditingGroup({ ...editingGroup, description: e.target.value })}
415
+ data-testid="edit-group-description"
416
+ style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box", height: "60px" }}
417
+ />
418
+ </div>
419
+ <div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
420
+ <button type="button" onClick={() => setEditingGroup(null)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
421
+ {labels.groupsView.cancel}
422
+ </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}
425
+ </button>
426
+ </div>
427
+ </form>
428
+ </div>
429
+ )}
430
+ </section>
431
+ )
432
+ }
433
+
434
+ /**
435
+ * View for managing group member assignments.
436
+ * グループメンバー割り当て管理ビュー。
437
+ */
438
+ export function GroupMembersView() {
439
+ const data = useLoaderData() as UserAdminLoaderData
440
+ const fetcher = useFetcher()
441
+ const labels = data.labels ?? defaultUserAdminLabels
442
+
443
+ const [selectedGroupId, setSelectedGroupId] = useState<number>(data.groups[0]?.id ?? 0)
444
+
445
+ const currentGroup = data.groups.find((g) => g.id === selectedGroupId)
446
+ const currentMemberUserIds = new Set(data.groupMembers.filter((m) => m.groupId === selectedGroupId).map((m) => m.userId))
447
+
448
+ const handleToggleMember = (userId: number, isMember: boolean) => {
449
+ if (isMember) {
450
+ fetcher.submit({ intent: "removeGroupMember", groupId: String(selectedGroupId), userId: String(userId) }, { method: "post" })
451
+ } else {
452
+ fetcher.submit({ intent: "addGroupMember", groupId: String(selectedGroupId), userId: String(userId) }, { method: "post" })
453
+ }
454
+ }
455
+
456
+ return (
457
+ <section data-testid="group-members-view-section" style={{ display: "grid", gridTemplateColumns: "240px 1fr", gap: "1.2rem" }}>
458
+ <ActionError fetcher={fetcher} />
459
+ <div style={{ backgroundColor: "#ffffff", padding: "1rem", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
460
+ <h4 style={{ margin: "0 0 0.8rem 0", fontSize: "0.9rem", color: "#475569" }}>{labels.groupMembersView.selectGroup}</h4>
461
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.4rem" }}>
462
+ {data.groups.map((g) => {
463
+ const isSelected = g.id === selectedGroupId
464
+ return (
465
+ <button
466
+ type="button"
467
+ key={g.id}
468
+ onClick={() => setSelectedGroupId(g.id)}
469
+ data-testid={`select-group-btn-${g.id}`}
470
+ style={{
471
+ textAlign: "left",
472
+ padding: "8px 10px",
473
+ borderRadius: "6px",
474
+ border: isSelected ? "1px solid #6366f1" : "1px solid #e2e8f0",
475
+ backgroundColor: isSelected ? "#e0e7ff" : "#ffffff",
476
+ color: isSelected ? "#3730a3" : "#0f172a",
477
+ cursor: "pointer",
478
+ fontWeight: isSelected ? 600 : 400,
479
+ fontSize: "0.85rem",
480
+ }}>
481
+ {g.name}
482
+ </button>
483
+ )
484
+ })}
485
+ </div>
486
+ </div>
487
+
488
+ <div style={{ backgroundColor: "#ffffff", padding: "1.2rem", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
489
+ {currentGroup ? (
490
+ <>
491
+ <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>
494
+ </div>
495
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
496
+ {data.users.map((u) => {
497
+ const isMember = currentMemberUserIds.has(u.id)
498
+ return (
499
+ <div
500
+ key={u.id}
501
+ style={{
502
+ display: "flex",
503
+ justifyContent: "space-between",
504
+ alignItems: "center",
505
+ padding: "8px 12px",
506
+ borderRadius: "6px",
507
+ border: "1px solid #f1f5f9",
508
+ backgroundColor: isMember ? "#f0fdf4" : "#ffffff",
509
+ }}
510
+ data-testid={`member-row-${u.id}`}>
511
+ <div>
512
+ <span style={{ fontWeight: 600, fontSize: "0.88rem", color: "#0f172a", marginRight: "8px" }}>{u.displayName}</span>
513
+ <span style={{ fontSize: "0.78rem", color: "#64748b" }}>({u.email})</span>
514
+ </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>
531
+ </div>
532
+ )
533
+ })}
534
+ </div>
535
+ </>
536
+ ) : (
537
+ <div style={{ color: "#94a3b8", textAlign: "center", padding: "2rem" }}>{labels.groupMembersView.selectPrompt}</div>
538
+ )}
539
+ </div>
540
+ </section>
541
+ )
542
+ }
543
+
544
+ /**
545
+ * View for managing authentication allowlist entries.
546
+ * アクセス許可リスト (Allowlist) 管理ビュー。
547
+ */
548
+ export function AllowlistView() {
549
+ const data = useLoaderData() as UserAdminLoaderData
550
+ const fetcher = useFetcher()
551
+ const labels = data.labels ?? defaultUserAdminLabels
552
+
553
+ const [pattern, setPattern] = useState("")
554
+ const [type, setType] = useState<"email" | "domain">("email")
555
+ const [description, setDescription] = useState("")
556
+
557
+ const handleAddEntry = (e: React.FormEvent) => {
558
+ e.preventDefault()
559
+ if (!pattern) return
560
+ fetcher.submit({ intent: "addAllowlistEntry", pattern, type, description }, { method: "post" })
561
+ setPattern("")
562
+ setDescription("")
563
+ }
564
+
565
+ const handleRemoveEntry = (id: number) => {
566
+ fetcher.submit({ intent: "removeAllowlistEntry", entryId: String(id) }, { method: "post" })
567
+ }
568
+
569
+ return (
570
+ <section data-testid="allowlist-view-section" style={{ display: "flex", flexDirection: "column", gap: "1.2rem" }}>
571
+ <ActionError fetcher={fetcher} />
572
+ <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
+ <div style={{ flex: 2, minWidth: "220px" }}>
574
+ <label htmlFor="allowlist-pattern-input-field" style={{ display: "block", fontSize: "0.78rem", color: "#475569", marginBottom: "4px" }}>
575
+ {labels.allowlistView.pattern}
576
+ </label>
577
+ <input
578
+ id="allowlist-pattern-input-field"
579
+ type="text"
580
+ value={pattern}
581
+ onChange={(e) => setPattern(e.target.value)}
582
+ required
583
+ data-testid="allowlist-pattern-input"
584
+ style={{ width: "100%", padding: "6px 10px", borderRadius: "6px", border: "1px solid #cbd5e1", fontSize: "0.85rem", boxSizing: "border-box" }}
585
+ />
586
+ </div>
587
+ <div style={{ flex: 1, minWidth: "140px" }}>
588
+ <label htmlFor="allowlist-type-select-field" style={{ display: "block", fontSize: "0.78rem", color: "#475569", marginBottom: "4px" }}>
589
+ {labels.allowlistView.type}
590
+ </label>
591
+ <select
592
+ id="allowlist-type-select-field"
593
+ value={type}
594
+ onChange={(e) => setType(e.target.value as "email" | "domain")}
595
+ data-testid="allowlist-type-select"
596
+ style={{ width: "100%", padding: "6px 10px", borderRadius: "6px", border: "1px solid #cbd5e1", fontSize: "0.85rem", boxSizing: "border-box" }}>
597
+ <option value="email">{labels.allowlistView.emailType}</option>
598
+ <option value="domain">{labels.allowlistView.domainType}</option>
599
+ </select>
600
+ </div>
601
+ <div style={{ flex: 2, minWidth: "200px" }}>
602
+ <label htmlFor="allowlist-desc-input-field" style={{ display: "block", fontSize: "0.78rem", color: "#475569", marginBottom: "4px" }}>
603
+ {labels.allowlistView.description}
604
+ </label>
605
+ <input
606
+ id="allowlist-desc-input-field"
607
+ type="text"
608
+ value={description}
609
+ onChange={(e) => setDescription(e.target.value)}
610
+ data-testid="allowlist-desc-input"
611
+ style={{ width: "100%", padding: "6px 10px", borderRadius: "6px", border: "1px solid #cbd5e1", fontSize: "0.85rem", boxSizing: "border-box" }}
612
+ />
613
+ </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" }}>
615
+ {labels.allowlistView.addEntry}
616
+ </button>
617
+ </form>
618
+
619
+ <div style={{ backgroundColor: "#ffffff", borderRadius: "8px", border: "1px solid #e2e8f0", overflow: "hidden" }}>
620
+ <table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.85rem", textAlign: "left" }}>
621
+ <thead>
622
+ <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>
625
+ <th style={{ padding: "10px 14px" }}>{labels.allowlistView.type}</th>
626
+ <th style={{ padding: "10px 14px" }}>{labels.allowlistView.description}</th>
627
+ <th style={{ padding: "10px 14px", textAlign: "right" }}>{labels.allowlistView.actions}</th>
628
+ </tr>
629
+ </thead>
630
+ <tbody>
631
+ {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>
635
+ <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>
639
+ </td>
640
+ <td style={{ padding: "10px 14px", color: "#475569" }}>{item.description || "-"}</td>
641
+ <td style={{ padding: "10px 14px", textAlign: "right" }}>
642
+ {/* 変更不能な供給元 (環境変数など) は削除ボタンを出さない */}
643
+ {item.readOnly ? (
644
+ <span data-testid={`readonly-allowlist-${item.id}`} style={{ fontSize: "0.75rem", color: "#94a3b8" }}>
645
+ {labels.allowlistView.readOnly}
646
+ </span>
647
+ ) : (
648
+ <button
649
+ type="button"
650
+ onClick={() => handleRemoveEntry(item.id)}
651
+ data-testid={`remove-allowlist-btn-${item.id}`}
652
+ style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #fee2e2", backgroundColor: "#fff5f5", color: "#b91c1c", fontSize: "0.78rem", cursor: "pointer" }}>
653
+ 削除
654
+ </button>
655
+ )}
656
+ </td>
657
+ </tr>
658
+ ))}
659
+ {data.allowlist.length === 0 && (
660
+ <tr>
661
+ <td colSpan={5} style={{ padding: "2rem", textAlign: "center", color: "#94a3b8" }}>
662
+ {labels.allowlistView.empty}
663
+ </td>
664
+ </tr>
665
+ )}
666
+ </tbody>
667
+ </table>
668
+ </div>
669
+ </section>
670
+ )
671
+ }
672
+
673
+ const SEGMENT_VIEWS: Record<string, () => React.ReactElement> = {
674
+ users: UsersView,
675
+ groups: GroupsView,
676
+ group_members: GroupMembersView,
677
+ allowlist: AllowlistView,
678
+ }
679
+
680
+ export type AuthUserAdminAppViewProps = UserAdminShellProps
681
+
682
+ /**
683
+ * Top-level application view component for the user administration UI (`/users/*`).
684
+ * ユーザー管理 UI 全体を描画するトップレベルコンポーネント。
685
+ */
686
+ export function AuthUserAdminAppView({ renderHeader }: AuthUserAdminAppViewProps = {}) {
687
+ const data = useLoaderData() as UserAdminLoaderData | undefined
688
+ const segment = data?.segment ?? "users"
689
+ const View = SEGMENT_VIEWS[segment] ?? UsersView
690
+
691
+ return (
692
+ <UserAdminShell renderHeader={renderHeader}>
693
+ <View />
694
+ </UserAdminShell>
695
+ )
696
+ }