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