@aiquants/auth-react-router 0.11.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 +160 -29
- package/dist/admin.d.ts +160 -29
- 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 +3 -3
- package/src/admin/format.ts +49 -0
- package/src/admin/labels.ts +300 -98
- package/src/admin/server.ts +95 -25
- package/src/admin/ui.tsx +75 -8
- package/src/admin/views.tsx +619 -149
package/src/admin/views.tsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type { AuthAllowlistEntry, AuthDirectoryStatus, 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
|
|
|
@@ -26,63 +27,193 @@ export type UserAdminLoaderData = {
|
|
|
26
27
|
* ロックアウト理由・重複パターン・入力不正などの文言は action が `{ok:false,error}` で返す。
|
|
27
28
|
* 表示しなければ操作が黙って失敗したように見えるため、各ビューの先頭に必ず置く。
|
|
28
29
|
*/
|
|
29
|
-
function ActionError({
|
|
30
|
-
const result =
|
|
30
|
+
function ActionError({ result: raw, id }: { result: unknown; id?: string }) {
|
|
31
|
+
const result = raw as { ok?: boolean; error?: string } | undefined
|
|
31
32
|
if (result?.ok !== false || !result.error) return null
|
|
32
33
|
return (
|
|
33
|
-
|
|
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" }}>
|
|
34
36
|
{result.error}
|
|
35
37
|
</div>
|
|
36
38
|
)
|
|
37
39
|
}
|
|
38
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
|
+
// 破棄した結果の世代。`fetcher.data` は宣言的に消せないため、こちらで無効化する
|
|
56
|
+
const [discarded, setDiscarded] = useState<unknown>(undefined)
|
|
57
|
+
const result = fetcher.data === discarded ? undefined : fetcher.data
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (fetcher.state !== "idle" || fetcher.data === undefined) return
|
|
61
|
+
const onSuccess = pending.current
|
|
62
|
+
if (!onSuccess) return
|
|
63
|
+
// 同じ応答で 2 度実行しないよう、消費した時点で捨てる。ここは **冗長な防御** である —
|
|
64
|
+
// すべての送信がこのフックを通る限り、後の送信が必ず `pending` を上書きするため。
|
|
65
|
+
// 素の `fetcher.submit` が 1 つでも混ざった瞬間に効き始める
|
|
66
|
+
pending.current = null
|
|
67
|
+
if ((fetcher.data as { ok?: boolean }).ok === true) onSuccess()
|
|
68
|
+
}, [fetcher.state, fetcher.data])
|
|
69
|
+
|
|
70
|
+
const submit = (payload: Record<string, string>, onSuccess?: () => void) => {
|
|
71
|
+
pending.current = onSuccess ?? null
|
|
72
|
+
setDiscarded(undefined)
|
|
73
|
+
fetcher.submit(payload, { method: "post" })
|
|
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 }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Gives a dialog the behavior its ARIA attributes promise: focus, trap, Escape, restore.
|
|
86
|
+
* ダイアログに、ARIA 属性が約束している挙動 (初期焦点・封じ込め・Escape・復帰) を与えるフック。
|
|
87
|
+
*
|
|
88
|
+
* 役割属性だけを付けても、焦点は背後に残り、Tab は外へ出て行き、Escape は効かない。
|
|
89
|
+
* キーボード利用者は「閉じる」に到達できず、支援技術は背後を読み続ける。
|
|
90
|
+
*
|
|
91
|
+
* @param isOpen Whether the dialog is mounted and visible. ダイアログが開いているかどうか。
|
|
92
|
+
* @param onClose Invoked when Escape is pressed. Escape が押されたときに呼ばれる処理。
|
|
93
|
+
* @returns Ref to attach to the dialog panel. ダイアログ本体へ付ける ref。
|
|
94
|
+
*/
|
|
95
|
+
function useModalBehavior(isOpen: boolean, onClose: () => void, openerRef?: React.RefObject<HTMLElement | null>) {
|
|
96
|
+
const ref = useRef<HTMLFormElement>(null)
|
|
97
|
+
// 再描画のたびに実行されると入力中に焦点が先頭へ飛ぶため、依存は開閉だけにする
|
|
98
|
+
const closeRef = useRef(onClose)
|
|
99
|
+
closeRef.current = onClose
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (!isOpen) return
|
|
103
|
+
const panel = ref.current
|
|
104
|
+
// `activeElement` を見るだけでは戻し先にならない。押下で焦点が乗らないブラウザ
|
|
105
|
+
// (Safari / macOS の Firefox) では body へ戻ってしまう。開いた要素を明示的に覚える
|
|
106
|
+
const opener = openerRef?.current ?? (document.activeElement as HTMLElement | null)
|
|
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"])') ?? [])
|
|
108
|
+
focusables()[0]?.focus()
|
|
109
|
+
|
|
110
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
111
|
+
if (event.key === "Escape") {
|
|
112
|
+
// 変換中の Escape は「候補を取り消す」であって「閉じる」ではない。ここで
|
|
113
|
+
// 閉じると、編集中の下書きは状態ごと捨てられ、打ち直しになる
|
|
114
|
+
if (event.isComposing || event.keyCode === 229) return
|
|
115
|
+
event.preventDefault()
|
|
116
|
+
closeRef.current()
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
if (event.key !== "Tab") return
|
|
120
|
+
const items = focusables()
|
|
121
|
+
if (items.length === 0) return
|
|
122
|
+
const first = items[0]
|
|
123
|
+
const last = items[items.length - 1]
|
|
124
|
+
const active = document.activeElement
|
|
125
|
+
const outside = !panel?.contains(active)
|
|
126
|
+
if (event.shiftKey && (active === first || outside)) {
|
|
127
|
+
event.preventDefault()
|
|
128
|
+
last.focus()
|
|
129
|
+
} else if (!event.shiftKey && (active === last || outside)) {
|
|
130
|
+
event.preventDefault()
|
|
131
|
+
first.focus()
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
document.addEventListener("keydown", onKeyDown)
|
|
135
|
+
return () => {
|
|
136
|
+
document.removeEventListener("keydown", onKeyDown)
|
|
137
|
+
opener?.focus?.()
|
|
138
|
+
}
|
|
139
|
+
}, [isOpen, openerRef])
|
|
140
|
+
|
|
141
|
+
return ref
|
|
142
|
+
}
|
|
143
|
+
|
|
39
144
|
export function UsersView() {
|
|
40
145
|
const data = useLoaderData() as UserAdminLoaderData
|
|
41
146
|
const fetcher = useFetcher()
|
|
147
|
+
const { submit: submitThen, resetResult, result: actionResult } = useSubmitThen(fetcher)
|
|
42
148
|
const labels = data.labels ?? defaultUserAdminLabels
|
|
43
149
|
|
|
44
150
|
const [searchQuery, setSearchQuery] = useState("")
|
|
45
|
-
|
|
46
|
-
|
|
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
|
+
}
|
|
47
163
|
|
|
48
164
|
const [newDisplayName, setNewDisplayName] = useState("")
|
|
49
165
|
const [newEmail, setNewEmail] = useState("")
|
|
166
|
+
const createUserRef = useModalBehavior(isCreateOpen, () => setModal(null), openerRef)
|
|
167
|
+
const editUserRef = useModalBehavior(editingUser !== null, () => setModal(null), openerRef)
|
|
50
168
|
|
|
169
|
+
const busy = fetcher.state !== "idle"
|
|
51
170
|
const filteredUsers = data.users.filter((u) => u.displayName.toLowerCase().includes(searchQuery.toLowerCase()) || u.email.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
52
171
|
|
|
53
172
|
const handleToggleActive = (user: AuthUser) => {
|
|
54
|
-
|
|
173
|
+
// 無効化はサインインを止める。有効化は元に戻すだけなので確認しない
|
|
174
|
+
if (user.isActive && !window.confirm(labels.usersView.confirmDeactivate)) return
|
|
175
|
+
submitThen({ intent: "toggleUserActive", userId: String(user.id), isActive: String(!user.isActive) })
|
|
55
176
|
}
|
|
56
177
|
|
|
57
178
|
const handleCreateUser = (e: React.FormEvent) => {
|
|
58
179
|
e.preventDefault()
|
|
59
180
|
if (!(newDisplayName && newEmail)) return
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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)
|
|
188
|
+
})
|
|
64
189
|
}
|
|
65
190
|
|
|
66
191
|
const handleUpdateUser = (e: React.FormEvent) => {
|
|
67
192
|
e.preventDefault()
|
|
68
193
|
if (!editingUser) return
|
|
69
|
-
|
|
70
|
-
setEditingUser(null)
|
|
194
|
+
submitThen({ intent: "updateUser", userId: String(editingUser.id), displayName: editingUser.displayName, email: editingUser.email }, () => setModal(null))
|
|
71
195
|
}
|
|
72
196
|
|
|
73
197
|
return (
|
|
74
198
|
<section data-testid="users-view-section" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
|
75
|
-
<
|
|
199
|
+
<h2 style={heading}>{labels.usersView.title}</h2>
|
|
200
|
+
{/* モーダルが開いている間はモーダル内に描く。両方描くと同じ hook が 2 つになる */}
|
|
201
|
+
{!modal && <ActionError result={actionResult} />}
|
|
76
202
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", backgroundColor: "#ffffff", padding: "1rem", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
|
|
77
203
|
<input
|
|
78
204
|
type="text"
|
|
205
|
+
aria-label={labels.usersView.searchLabel}
|
|
79
206
|
placeholder={labels.usersView.searchPlaceholder}
|
|
80
207
|
value={searchQuery}
|
|
81
208
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
82
209
|
data-testid="users-search-input"
|
|
83
210
|
style={{ padding: "6px 12px", borderRadius: "6px", border: "1px solid #cbd5e1", fontSize: "0.85rem", width: "260px" }}
|
|
84
211
|
/>
|
|
85
|
-
<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" }}>
|
|
86
217
|
{labels.usersView.addUser}
|
|
87
218
|
</button>
|
|
88
219
|
</div>
|
|
@@ -91,17 +222,17 @@ export function UsersView() {
|
|
|
91
222
|
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.85rem", textAlign: "left" }}>
|
|
92
223
|
<thead>
|
|
93
224
|
<tr style={{ backgroundColor: "#f8fafc", borderBottom: "1px solid #e2e8f0", color: "#475569" }}>
|
|
94
|
-
<th style={{ padding: "10px 14px" }}>{labels.
|
|
225
|
+
<th style={{ padding: "10px 14px" }}>{labels.common.id}</th>
|
|
95
226
|
<th style={{ padding: "10px 14px" }}>{labels.usersView.displayName}</th>
|
|
96
227
|
<th style={{ padding: "10px 14px" }}>{labels.usersView.email}</th>
|
|
97
|
-
<th style={{ padding: "10px 14px" }}>{labels.
|
|
98
|
-
<th style={{ padding: "10px 14px", textAlign: "right" }}>{labels.
|
|
228
|
+
<th style={{ padding: "10px 14px" }}>{labels.common.status}</th>
|
|
229
|
+
<th style={{ padding: "10px 14px", textAlign: "right" }}>{labels.common.actions}</th>
|
|
99
230
|
</tr>
|
|
100
231
|
</thead>
|
|
101
232
|
<tbody>
|
|
102
233
|
{filteredUsers.map((u) => (
|
|
103
234
|
<tr key={u.id} style={{ borderBottom: "1px solid #f1f5f9" }} data-testid={`user-row-${u.id}`}>
|
|
104
|
-
<td style={{ padding: "10px 14px", color: "#
|
|
235
|
+
<td style={{ padding: "10px 14px", color: "#475569" }}>{fill(labels.common.idTemplate, { id: u.id })}</td>
|
|
105
236
|
<td style={{ padding: "10px 14px", fontWeight: 600, color: "#0f172a" }}>{u.displayName}</td>
|
|
106
237
|
<td style={{ padding: "10px 14px", color: "#475569" }}>{u.email}</td>
|
|
107
238
|
<td style={{ padding: "10px 14px" }}>
|
|
@@ -112,16 +243,20 @@ export function UsersView() {
|
|
|
112
243
|
<td style={{ padding: "10px 14px", textAlign: "right" }}>
|
|
113
244
|
<button
|
|
114
245
|
type="button"
|
|
115
|
-
onClick={() =>
|
|
246
|
+
onClick={(event) => openModal({ kind: "edit", user: u }, event)}
|
|
247
|
+
aria-label={fill(labels.usersView.editUserAria, { user: u.displayName })}
|
|
116
248
|
data-testid={`edit-user-btn-${u.id}`}
|
|
117
249
|
style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", fontSize: "0.78rem", cursor: "pointer", marginRight: "6px" }}>
|
|
118
|
-
{labels.
|
|
250
|
+
{labels.common.edit}
|
|
119
251
|
</button>
|
|
120
252
|
<button
|
|
121
253
|
type="button"
|
|
122
254
|
onClick={() => handleToggleActive(u)}
|
|
255
|
+
disabled={busy}
|
|
256
|
+
aria-busy={busy}
|
|
257
|
+
aria-label={fill(u.isActive ? labels.usersView.deactivateAria : labels.usersView.activateAria, { user: u.displayName })}
|
|
123
258
|
data-testid={`toggle-user-btn-${u.id}`}
|
|
124
|
-
style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", fontSize: "0.78rem", cursor: "pointer", color: u.isActive ? "#b91c1c" : "#15803d" }}>
|
|
259
|
+
style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", fontSize: "0.78rem", cursor: busy ? "default" : "pointer", color: u.isActive ? "#b91c1c" : "#15803d" }}>
|
|
125
260
|
{u.isActive ? labels.usersView.deactivate : labels.usersView.activate}
|
|
126
261
|
</button>
|
|
127
262
|
</td>
|
|
@@ -129,7 +264,7 @@ export function UsersView() {
|
|
|
129
264
|
))}
|
|
130
265
|
{filteredUsers.length === 0 && (
|
|
131
266
|
<tr>
|
|
132
|
-
<td colSpan={5} style={{ padding: "2rem", textAlign: "center", color: "#
|
|
267
|
+
<td colSpan={5} style={{ padding: "2rem", textAlign: "center", color: "#475569" }}>
|
|
133
268
|
{labels.usersView.empty}
|
|
134
269
|
</td>
|
|
135
270
|
</tr>
|
|
@@ -140,9 +275,20 @@ export function UsersView() {
|
|
|
140
275
|
|
|
141
276
|
{/* Create Modal */}
|
|
142
277
|
{isCreateOpen && (
|
|
143
|
-
<div style={{ position: "fixed", inset: 0, backgroundColor: "rgba(0,0,0,0.4)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 100 }}
|
|
144
|
-
<form
|
|
145
|
-
|
|
278
|
+
<div style={{ position: "fixed", inset: 0, backgroundColor: "rgba(0,0,0,0.4)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 100 }}>
|
|
279
|
+
<form
|
|
280
|
+
ref={createUserRef}
|
|
281
|
+
onSubmit={handleCreateUser}
|
|
282
|
+
role="dialog"
|
|
283
|
+
aria-modal="true"
|
|
284
|
+
aria-labelledby="create-user-title"
|
|
285
|
+
aria-describedby="create-user-error"
|
|
286
|
+
data-testid="user-create-modal"
|
|
287
|
+
style={{ backgroundColor: "#ffffff", padding: "1.5rem", borderRadius: "8px", width: "360px", boxShadow: "0 10px 25px rgba(0,0,0,0.15)" }}>
|
|
288
|
+
<h3 id="create-user-title" style={{ margin: "0 0 1rem 0", fontSize: "1.1rem" }}>
|
|
289
|
+
{labels.usersView.addUser}
|
|
290
|
+
</h3>
|
|
291
|
+
<ActionError result={actionResult} id="create-user-error" />
|
|
146
292
|
<div style={{ marginBottom: "1rem" }}>
|
|
147
293
|
<label htmlFor="create-user-display-name-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
|
|
148
294
|
{labels.usersView.displayName}
|
|
@@ -172,11 +318,11 @@ export function UsersView() {
|
|
|
172
318
|
/>
|
|
173
319
|
</div>
|
|
174
320
|
<div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
|
|
175
|
-
<button type="button" onClick={() =>
|
|
176
|
-
|
|
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}
|
|
177
323
|
</button>
|
|
178
|
-
<button type="submit" data-testid="submit-create-user" style={{ padding: "6px 12px", borderRadius: "4px", backgroundColor: "#0284c7", color: "#ffffff", border: "none", cursor: "pointer" }}>
|
|
179
|
-
|
|
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" }}>
|
|
325
|
+
{labels.common.create}
|
|
180
326
|
</button>
|
|
181
327
|
</div>
|
|
182
328
|
</form>
|
|
@@ -185,9 +331,20 @@ export function UsersView() {
|
|
|
185
331
|
|
|
186
332
|
{/* Edit Modal */}
|
|
187
333
|
{editingUser && (
|
|
188
|
-
<div style={{ position: "fixed", inset: 0, backgroundColor: "rgba(0,0,0,0.4)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 100 }}
|
|
189
|
-
<form
|
|
190
|
-
|
|
334
|
+
<div style={{ position: "fixed", inset: 0, backgroundColor: "rgba(0,0,0,0.4)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 100 }}>
|
|
335
|
+
<form
|
|
336
|
+
ref={editUserRef}
|
|
337
|
+
onSubmit={handleUpdateUser}
|
|
338
|
+
role="dialog"
|
|
339
|
+
aria-modal="true"
|
|
340
|
+
aria-labelledby="edit-user-title"
|
|
341
|
+
aria-describedby="edit-user-error"
|
|
342
|
+
data-testid="user-edit-modal"
|
|
343
|
+
style={{ backgroundColor: "#ffffff", padding: "1.5rem", borderRadius: "8px", width: "360px", boxShadow: "0 10px 25px rgba(0,0,0,0.15)" }}>
|
|
344
|
+
<h3 id="edit-user-title" style={{ margin: "0 0 1rem 0", fontSize: "1.1rem" }}>
|
|
345
|
+
{labels.usersView.editUser}
|
|
346
|
+
</h3>
|
|
347
|
+
<ActionError result={actionResult} id="edit-user-error" />
|
|
191
348
|
<div style={{ marginBottom: "1rem" }}>
|
|
192
349
|
<label htmlFor="edit-user-display-name-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
|
|
193
350
|
{labels.usersView.displayName}
|
|
@@ -196,7 +353,7 @@ export function UsersView() {
|
|
|
196
353
|
id="edit-user-display-name-input"
|
|
197
354
|
type="text"
|
|
198
355
|
value={editingUser.displayName}
|
|
199
|
-
onChange={(e) =>
|
|
356
|
+
onChange={(e) => setModal({ kind: "edit", user: { ...editingUser, displayName: e.target.value } })}
|
|
200
357
|
required
|
|
201
358
|
data-testid="edit-user-display-name"
|
|
202
359
|
style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box" }}
|
|
@@ -210,18 +367,18 @@ export function UsersView() {
|
|
|
210
367
|
id="edit-user-email-input"
|
|
211
368
|
type="email"
|
|
212
369
|
value={editingUser.email}
|
|
213
|
-
onChange={(e) =>
|
|
370
|
+
onChange={(e) => setModal({ kind: "edit", user: { ...editingUser, email: e.target.value } })}
|
|
214
371
|
required
|
|
215
372
|
data-testid="edit-user-email"
|
|
216
373
|
style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box" }}
|
|
217
374
|
/>
|
|
218
375
|
</div>
|
|
219
376
|
<div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
|
|
220
|
-
<button type="button" onClick={() =>
|
|
221
|
-
{labels.
|
|
377
|
+
<button type="button" onClick={() => setModal(null)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
|
|
378
|
+
{labels.common.cancel}
|
|
222
379
|
</button>
|
|
223
|
-
<button type="submit" data-testid="submit-edit-user" style={{ padding: "6px 12px", borderRadius: "4px", backgroundColor: "#0284c7", color: "#ffffff", border: "none", cursor: "pointer" }}>
|
|
224
|
-
{labels.
|
|
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" }}>
|
|
381
|
+
{labels.common.save}
|
|
225
382
|
</button>
|
|
226
383
|
</div>
|
|
227
384
|
</form>
|
|
@@ -235,84 +392,76 @@ export function UsersView() {
|
|
|
235
392
|
* View for managing user groups (create, edit, delete).
|
|
236
393
|
* ユーザーグループ管理ビュー (作成・編集・削除)。
|
|
237
394
|
*/
|
|
238
|
-
/**
|
|
239
|
-
* Warns when synchronization is not actually keeping the mirror fresh.
|
|
240
|
-
* 同期が実際には鏡を新しく保てていないことを警告する部品。
|
|
241
|
-
*
|
|
242
|
-
* 同期の停止は「上流から外れた人がテナントに居続ける」というセキュリティ事象である。件数と
|
|
243
|
-
* エラー数だけを見ると何も起きていないように読めるため、**滞留・全停止・未結線の 3 つを
|
|
244
|
-
* それぞれ名指しで** 出す。出さなければ、状態を計算している意味がない。
|
|
245
|
-
*/
|
|
246
|
-
function DirectorySyncBanner({ status, labels }: { status: AuthDirectoryStatus; labels: UserAdminLabels }) {
|
|
247
|
-
if (status.linkedGroupCount === 0) return null
|
|
248
|
-
const warnings: Array<{ id: string; text: string }> = []
|
|
249
|
-
if (!status.isEnabled) warnings.push({ id: "disabled", text: labels.directoryView.disabledWarning })
|
|
250
|
-
if (status.pausedGroupCount > 0) warnings.push({ id: "paused", text: `${labels.directoryView.syncPaused}: ${status.pausedGroupCount} / ${status.linkedGroupCount}` })
|
|
251
|
-
if (status.isStale) warnings.push({ id: "stale", text: labels.directoryView.staleWarning })
|
|
252
|
-
if (status.erroredGroupIds.length > 0) warnings.push({ id: "errored", text: `${labels.directoryView.title}: ${status.erroredGroupIds.join(", ")}` })
|
|
253
|
-
if (warnings.length === 0) return null
|
|
254
|
-
return (
|
|
255
|
-
<div data-testid="directory-sync-banner" role="status" style={{ display: "flex", flexDirection: "column", gap: "0.35rem", padding: "0.7rem 1rem", borderRadius: "6px", border: "1px solid #fde68a", backgroundColor: "#fffbeb", color: "#92400e", fontSize: "0.85rem" }}>
|
|
256
|
-
{warnings.map((w) => (
|
|
257
|
-
<span key={w.id} data-directory-warning={w.id}>
|
|
258
|
-
{w.text}
|
|
259
|
-
</span>
|
|
260
|
-
))}
|
|
261
|
-
</div>
|
|
262
|
-
)
|
|
263
|
-
}
|
|
264
|
-
|
|
265
395
|
export function GroupsView() {
|
|
266
396
|
const data = useLoaderData() as UserAdminLoaderData
|
|
267
397
|
const fetcher = useFetcher()
|
|
398
|
+
const { submit: submitThen, resetResult, result: actionResult } = useSubmitThen(fetcher)
|
|
268
399
|
const labels = data.labels ?? defaultUserAdminLabels
|
|
269
400
|
|
|
270
401
|
const [searchQuery, setSearchQuery] = useState("")
|
|
271
|
-
const [
|
|
272
|
-
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
|
+
}
|
|
273
412
|
|
|
413
|
+
const busy = fetcher.state !== "idle"
|
|
274
414
|
const [newGroupKey, setNewGroupKey] = useState("")
|
|
275
415
|
const [newName, setNewName] = useState("")
|
|
276
416
|
const [newDescription, setNewDescription] = useState("")
|
|
417
|
+
const createGroupRef = useModalBehavior(isCreateOpen, () => setModal(null), openerRef)
|
|
418
|
+
const editGroupRef = useModalBehavior(editingGroup !== null, () => setModal(null), openerRef)
|
|
277
419
|
|
|
278
420
|
const filteredGroups = data.groups.filter((g) => g.name.toLowerCase().includes(searchQuery.toLowerCase()) || g.groupKey.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
279
421
|
|
|
280
422
|
const handleCreateGroup = (e: React.FormEvent) => {
|
|
281
423
|
e.preventDefault()
|
|
282
424
|
if (!(newGroupKey && newName)) return
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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)
|
|
432
|
+
})
|
|
288
433
|
}
|
|
289
434
|
|
|
290
435
|
const handleUpdateGroup = (e: React.FormEvent) => {
|
|
291
436
|
e.preventDefault()
|
|
292
437
|
if (!editingGroup) return
|
|
293
|
-
|
|
294
|
-
setEditingGroup(null)
|
|
438
|
+
submitThen({ intent: "updateGroup", groupId: String(editingGroup.id), name: editingGroup.name, description: editingGroup.description ?? "" }, () => setModal(null))
|
|
295
439
|
}
|
|
296
440
|
|
|
297
441
|
const handleDeleteGroup = (groupId: number) => {
|
|
298
442
|
if (!window.confirm(labels.groupsView.confirmDelete)) return
|
|
299
|
-
|
|
443
|
+
submitThen({ intent: "deleteGroup", groupId: String(groupId) })
|
|
300
444
|
}
|
|
301
445
|
|
|
302
446
|
return (
|
|
303
447
|
<section data-testid="groups-view-section" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
|
304
|
-
<
|
|
305
|
-
<
|
|
448
|
+
<h2 style={heading}>{labels.groupsView.title}</h2>
|
|
449
|
+
{!modal && <ActionError result={actionResult} />}
|
|
306
450
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", backgroundColor: "#ffffff", padding: "1rem", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
|
|
307
451
|
<input
|
|
308
452
|
type="text"
|
|
453
|
+
aria-label={labels.groupsView.searchLabel}
|
|
309
454
|
placeholder={labels.groupsView.searchPlaceholder}
|
|
310
455
|
value={searchQuery}
|
|
311
456
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
312
457
|
data-testid="groups-search-input"
|
|
313
458
|
style={{ padding: "6px 12px", borderRadius: "6px", border: "1px solid #cbd5e1", fontSize: "0.85rem", width: "260px" }}
|
|
314
459
|
/>
|
|
315
|
-
<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" }}>
|
|
316
465
|
{labels.groupsView.addGroup}
|
|
317
466
|
</button>
|
|
318
467
|
</div>
|
|
@@ -335,41 +484,47 @@ export function GroupsView() {
|
|
|
335
484
|
{/* 所属も名称も上流が所有していることを、操作を試す前に伝える */}
|
|
336
485
|
{g.externalLink && (
|
|
337
486
|
<div data-testid={`group-source-${g.id}`} style={{ display: "flex", alignItems: "center", gap: "6px", flexWrap: "wrap", marginBottom: "0.6rem" }}>
|
|
338
|
-
<span style={{ fontSize: "0.72rem", padding: "2px 8px", borderRadius: "10px", backgroundColor: "#ede9fe", color: "#6d28d9", fontWeight: 600 }}>{labels.
|
|
487
|
+
<span style={{ fontSize: "0.72rem", padding: "2px 8px", borderRadius: "10px", backgroundColor: "#ede9fe", color: "#6d28d9", fontWeight: 600 }}>{labels.common.managedUpstream}</span>
|
|
339
488
|
<span style={{ fontSize: "0.72rem", fontFamily: "monospace", color: "#64748b" }}>{g.externalLink.externalKey}</span>
|
|
340
489
|
{g.externalLink.unresolvedCount > 0 && (
|
|
341
490
|
<span data-testid={`group-unresolved-${g.id}`} style={{ fontSize: "0.72rem", color: "#b45309" }}>
|
|
342
|
-
{g.externalLink.unresolvedCount
|
|
491
|
+
{fill(labels.directoryView.unresolvedTemplate, { count: g.externalLink.unresolvedCount })}
|
|
343
492
|
</span>
|
|
344
493
|
)}
|
|
345
494
|
</div>
|
|
346
495
|
)}
|
|
347
496
|
</div>
|
|
348
497
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", borderTop: "1px solid #f1f5f9", paddingTop: "0.6rem" }}>
|
|
349
|
-
<span style={{ fontSize: "0.78rem", color: "#475569" }}>
|
|
350
|
-
|
|
498
|
+
<span style={{ fontSize: "0.78rem", color: "#475569" }} data-testid={`group-member-count-${g.id}`}>
|
|
499
|
+
{fill(labels.groupsView.memberCountTemplate, { count: memberCount })}
|
|
351
500
|
</span>
|
|
352
501
|
{/* 上流が所有する行では操作を **描かない**。無効化したボタンを出すと、
|
|
353
502
|
押せる操作と見分けがつかず、押せない理由も伝わらない */}
|
|
354
503
|
{g.readOnly ? (
|
|
355
|
-
<span data-testid={`readonly-group-${g.id}`} style={{ fontSize: "0.75rem", color: "#
|
|
356
|
-
{labels.
|
|
504
|
+
<span data-testid={`readonly-group-${g.id}`} style={{ fontSize: "0.75rem", color: "#475569" }}>
|
|
505
|
+
{labels.common.readOnly}
|
|
357
506
|
</span>
|
|
358
507
|
) : (
|
|
359
508
|
<div>
|
|
360
509
|
<button
|
|
361
510
|
type="button"
|
|
362
|
-
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 })}
|
|
363
515
|
data-testid={`edit-group-btn-${g.id}`}
|
|
364
516
|
style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", fontSize: "0.75rem", cursor: "pointer", marginRight: "4px" }}>
|
|
365
|
-
{labels.
|
|
517
|
+
{labels.common.edit}
|
|
366
518
|
</button>
|
|
367
519
|
<button
|
|
368
520
|
type="button"
|
|
369
521
|
onClick={() => handleDeleteGroup(g.id)}
|
|
522
|
+
disabled={busy}
|
|
523
|
+
aria-busy={busy}
|
|
524
|
+
aria-label={fill(labels.groupsView.deleteGroupAria, { group: g.name })}
|
|
370
525
|
data-testid={`delete-group-btn-${g.id}`}
|
|
371
526
|
style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #fee2e2", backgroundColor: "#fff5f5", color: "#b91c1c", fontSize: "0.75rem", cursor: "pointer" }}>
|
|
372
|
-
{labels.
|
|
527
|
+
{labels.common.delete}
|
|
373
528
|
</button>
|
|
374
529
|
</div>
|
|
375
530
|
)}
|
|
@@ -378,12 +533,28 @@ export function GroupsView() {
|
|
|
378
533
|
)
|
|
379
534
|
})}
|
|
380
535
|
</div>
|
|
536
|
+
{filteredGroups.length === 0 && (
|
|
537
|
+
<div data-testid="groups-empty" style={{ padding: "2rem", textAlign: "center", color: "#475569", backgroundColor: "#ffffff", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
|
|
538
|
+
{data.groups.length === 0 ? labels.groupsView.empty : labels.groupsView.emptyFiltered}
|
|
539
|
+
</div>
|
|
540
|
+
)}
|
|
381
541
|
|
|
382
542
|
{/* Create Group Modal */}
|
|
383
543
|
{isCreateOpen && (
|
|
384
|
-
<div style={{ position: "fixed", inset: 0, backgroundColor: "rgba(0,0,0,0.4)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 100 }}
|
|
385
|
-
<form
|
|
386
|
-
|
|
544
|
+
<div style={{ position: "fixed", inset: 0, backgroundColor: "rgba(0,0,0,0.4)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 100 }}>
|
|
545
|
+
<form
|
|
546
|
+
ref={createGroupRef}
|
|
547
|
+
onSubmit={handleCreateGroup}
|
|
548
|
+
role="dialog"
|
|
549
|
+
aria-modal="true"
|
|
550
|
+
aria-labelledby="create-group-title"
|
|
551
|
+
aria-describedby="create-group-error"
|
|
552
|
+
data-testid="group-create-modal"
|
|
553
|
+
style={{ backgroundColor: "#ffffff", padding: "1.5rem", borderRadius: "8px", width: "360px", boxShadow: "0 10px 25px rgba(0,0,0,0.15)" }}>
|
|
554
|
+
<h3 id="create-group-title" style={{ margin: "0 0 1rem 0", fontSize: "1.1rem" }}>
|
|
555
|
+
{labels.groupsView.addGroup}
|
|
556
|
+
</h3>
|
|
557
|
+
<ActionError result={actionResult} id="create-group-error" />
|
|
387
558
|
<div style={{ marginBottom: "1rem" }}>
|
|
388
559
|
<label htmlFor="create-group-key-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
|
|
389
560
|
{labels.groupsView.groupKey}
|
|
@@ -427,11 +598,11 @@ export function GroupsView() {
|
|
|
427
598
|
/>
|
|
428
599
|
</div>
|
|
429
600
|
<div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
|
|
430
|
-
<button type="button" onClick={() =>
|
|
431
|
-
|
|
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}
|
|
432
603
|
</button>
|
|
433
|
-
<button type="submit" data-testid="submit-create-group" style={{ padding: "6px 12px", borderRadius: "4px", backgroundColor: "#4338ca", color: "#ffffff", border: "none", cursor: "pointer" }}>
|
|
434
|
-
|
|
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" }}>
|
|
605
|
+
{labels.common.create}
|
|
435
606
|
</button>
|
|
436
607
|
</div>
|
|
437
608
|
</form>
|
|
@@ -440,9 +611,20 @@ export function GroupsView() {
|
|
|
440
611
|
|
|
441
612
|
{/* Edit Group Modal */}
|
|
442
613
|
{editingGroup && (
|
|
443
|
-
<div style={{ position: "fixed", inset: 0, backgroundColor: "rgba(0,0,0,0.4)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 100 }}
|
|
444
|
-
<form
|
|
445
|
-
|
|
614
|
+
<div style={{ position: "fixed", inset: 0, backgroundColor: "rgba(0,0,0,0.4)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 100 }}>
|
|
615
|
+
<form
|
|
616
|
+
ref={editGroupRef}
|
|
617
|
+
onSubmit={handleUpdateGroup}
|
|
618
|
+
role="dialog"
|
|
619
|
+
aria-modal="true"
|
|
620
|
+
aria-labelledby="edit-group-title"
|
|
621
|
+
aria-describedby="edit-group-error"
|
|
622
|
+
data-testid="group-edit-modal"
|
|
623
|
+
style={{ backgroundColor: "#ffffff", padding: "1.5rem", borderRadius: "8px", width: "360px", boxShadow: "0 10px 25px rgba(0,0,0,0.15)" }}>
|
|
624
|
+
<h3 id="edit-group-title" style={{ margin: "0 0 1rem 0", fontSize: "1.1rem" }}>
|
|
625
|
+
{labels.groupsView.editGroup}
|
|
626
|
+
</h3>
|
|
627
|
+
<ActionError result={actionResult} id="edit-group-error" />
|
|
446
628
|
<div style={{ marginBottom: "1rem" }}>
|
|
447
629
|
<label htmlFor="edit-group-name-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
|
|
448
630
|
{labels.groupsView.name}
|
|
@@ -451,7 +633,7 @@ export function GroupsView() {
|
|
|
451
633
|
id="edit-group-name-input"
|
|
452
634
|
type="text"
|
|
453
635
|
value={editingGroup.name}
|
|
454
|
-
onChange={(e) =>
|
|
636
|
+
onChange={(e) => setModal({ kind: "edit", group: { ...editingGroup, name: e.target.value } })}
|
|
455
637
|
required
|
|
456
638
|
data-testid="edit-group-name"
|
|
457
639
|
style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box" }}
|
|
@@ -464,17 +646,17 @@ export function GroupsView() {
|
|
|
464
646
|
<textarea
|
|
465
647
|
id="edit-group-desc-input"
|
|
466
648
|
value={editingGroup.description ?? ""}
|
|
467
|
-
onChange={(e) =>
|
|
649
|
+
onChange={(e) => setModal({ kind: "edit", group: { ...editingGroup, description: e.target.value } })}
|
|
468
650
|
data-testid="edit-group-description"
|
|
469
651
|
style={{ width: "100%", padding: "6px", borderRadius: "4px", border: "1px solid #cbd5e1", boxSizing: "border-box", height: "60px" }}
|
|
470
652
|
/>
|
|
471
653
|
</div>
|
|
472
654
|
<div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
|
|
473
|
-
<button type="button" onClick={() =>
|
|
474
|
-
{labels.
|
|
655
|
+
<button type="button" onClick={() => setModal(null)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
|
|
656
|
+
{labels.common.cancel}
|
|
475
657
|
</button>
|
|
476
|
-
<button type="submit" data-testid="submit-edit-group" style={{ padding: "6px 12px", borderRadius: "4px", backgroundColor: "#4338ca", color: "#ffffff", border: "none", cursor: "pointer" }}>
|
|
477
|
-
{labels.
|
|
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" }}>
|
|
659
|
+
{labels.common.save}
|
|
478
660
|
</button>
|
|
479
661
|
</div>
|
|
480
662
|
</form>
|
|
@@ -491,24 +673,28 @@ export function GroupsView() {
|
|
|
491
673
|
export function GroupMembersView() {
|
|
492
674
|
const data = useLoaderData() as UserAdminLoaderData
|
|
493
675
|
const fetcher = useFetcher()
|
|
676
|
+
const { submit: submitThen, result: actionResult } = useSubmitThen(fetcher)
|
|
677
|
+
const busy = fetcher.state !== "idle"
|
|
494
678
|
const labels = data.labels ?? defaultUserAdminLabels
|
|
495
679
|
|
|
496
680
|
const [selectedGroupId, setSelectedGroupId] = useState<number>(data.groups[0]?.id ?? 0)
|
|
497
681
|
|
|
498
|
-
|
|
682
|
+
// 再検証で対象が消えることがある。消えた ID を握り続けると空の面になる
|
|
683
|
+
const currentGroup = data.groups.find((g) => g.id === selectedGroupId) ?? data.groups[0]
|
|
499
684
|
const currentMemberUserIds = new Set(data.groupMembers.filter((m) => m.groupId === selectedGroupId).map((m) => m.userId))
|
|
500
685
|
|
|
501
686
|
const handleToggleMember = (userId: number, isMember: boolean) => {
|
|
502
687
|
if (isMember) {
|
|
503
|
-
|
|
688
|
+
submitThen({ intent: "removeGroupMember", groupId: String(selectedGroupId), userId: String(userId) })
|
|
504
689
|
} else {
|
|
505
|
-
|
|
690
|
+
submitThen({ intent: "addGroupMember", groupId: String(selectedGroupId), userId: String(userId) })
|
|
506
691
|
}
|
|
507
692
|
}
|
|
508
693
|
|
|
509
694
|
return (
|
|
510
695
|
<section data-testid="group-members-view-section" style={{ display: "grid", gridTemplateColumns: "240px 1fr", gap: "1.2rem" }}>
|
|
511
|
-
<
|
|
696
|
+
<h2 style={{ ...heading, gridColumn: "1 / -1" }}>{labels.groupMembersView.title}</h2>
|
|
697
|
+
<ActionError result={actionResult} />
|
|
512
698
|
<div style={{ backgroundColor: "#ffffff", padding: "1rem", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
|
|
513
699
|
<h4 style={{ margin: "0 0 0.8rem 0", fontSize: "0.9rem", color: "#475569" }}>{labels.groupMembersView.selectGroup}</h4>
|
|
514
700
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.4rem" }}>
|
|
@@ -532,6 +718,11 @@ export function GroupMembersView() {
|
|
|
532
718
|
fontSize: "0.85rem",
|
|
533
719
|
}}>
|
|
534
720
|
{g.name}
|
|
721
|
+
{g.readOnly ? (
|
|
722
|
+
<span data-testid={`group-upstream-marker-${g.id}`} style={{ marginLeft: "6px", fontSize: "0.7rem", color: "#7c3aed" }}>
|
|
723
|
+
{labels.common.managedUpstream}
|
|
724
|
+
</span>
|
|
725
|
+
) : null}
|
|
535
726
|
</button>
|
|
536
727
|
)
|
|
537
728
|
})}
|
|
@@ -542,8 +733,15 @@ export function GroupMembersView() {
|
|
|
542
733
|
{currentGroup ? (
|
|
543
734
|
<>
|
|
544
735
|
<div style={{ marginBottom: "1rem", borderBottom: "1px solid #e2e8f0", paddingBottom: "0.8rem" }}>
|
|
545
|
-
<h3 style={{ margin: 0, fontSize: "1.1rem", color: "#0f172a" }}>{currentGroup.name}
|
|
546
|
-
<span style={{ fontSize: "0.8rem", color: "#64748b" }}
|
|
736
|
+
<h3 style={{ margin: 0, fontSize: "1.1rem", color: "#0f172a" }}>{fill(labels.groupMembersView.membersOfTemplate, { group: currentGroup.name })}</h3>
|
|
737
|
+
<span style={{ fontSize: "0.8rem", color: "#64748b" }}>
|
|
738
|
+
{labels.groupMembersView.groupKeyLabel}: {currentGroup.groupKey}
|
|
739
|
+
</span>
|
|
740
|
+
{currentGroup.readOnly ? (
|
|
741
|
+
<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" }}>
|
|
742
|
+
{labels.groupMembersView.upstreamNotice}
|
|
743
|
+
</div>
|
|
744
|
+
) : null}
|
|
547
745
|
</div>
|
|
548
746
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
|
549
747
|
{data.users.map((u) => {
|
|
@@ -565,29 +763,40 @@ export function GroupMembersView() {
|
|
|
565
763
|
<span style={{ fontWeight: 600, fontSize: "0.88rem", color: "#0f172a", marginRight: "8px" }}>{u.displayName}</span>
|
|
566
764
|
<span style={{ fontSize: "0.78rem", color: "#64748b" }}>({u.email})</span>
|
|
567
765
|
</div>
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
766
|
+
{currentGroup.readOnly ? (
|
|
767
|
+
isMember ? (
|
|
768
|
+
<span data-testid={`member-readonly-${u.id}`} style={{ fontSize: "0.75rem", color: "#475569" }}>
|
|
769
|
+
{labels.common.managedUpstream}
|
|
770
|
+
</span>
|
|
771
|
+
) : null
|
|
772
|
+
) : (
|
|
773
|
+
<button
|
|
774
|
+
type="button"
|
|
775
|
+
onClick={() => handleToggleMember(u.id, isMember)}
|
|
776
|
+
disabled={busy}
|
|
777
|
+
aria-busy={busy}
|
|
778
|
+
aria-label={fill(isMember ? labels.groupMembersView.actionRemoveAria : labels.groupMembersView.actionAddAria, { user: u.displayName })}
|
|
779
|
+
data-testid={`toggle-member-btn-${u.id}`}
|
|
780
|
+
style={{
|
|
781
|
+
padding: "4px 12px",
|
|
782
|
+
borderRadius: "4px",
|
|
783
|
+
border: isMember ? "1px solid #b91c1c" : "1px solid #0284c7",
|
|
784
|
+
backgroundColor: isMember ? "#ffffff" : "#0284c7",
|
|
785
|
+
color: isMember ? "#b91c1c" : "#ffffff",
|
|
786
|
+
fontSize: "0.78rem",
|
|
787
|
+
fontWeight: 600,
|
|
788
|
+
cursor: "pointer",
|
|
789
|
+
}}>
|
|
790
|
+
{isMember ? labels.groupMembersView.actionRemove : labels.groupMembersView.actionAdd}
|
|
791
|
+
</button>
|
|
792
|
+
)}
|
|
584
793
|
</div>
|
|
585
794
|
)
|
|
586
795
|
})}
|
|
587
796
|
</div>
|
|
588
797
|
</>
|
|
589
798
|
) : (
|
|
590
|
-
<div style={{ color: "#
|
|
799
|
+
<div style={{ color: "#475569", textAlign: "center", padding: "2rem" }}>{labels.groupMembersView.selectPrompt}</div>
|
|
591
800
|
)}
|
|
592
801
|
</div>
|
|
593
802
|
</section>
|
|
@@ -617,6 +826,8 @@ export function AllowlistView() {
|
|
|
617
826
|
const fetcher = useFetcher()
|
|
618
827
|
const labels = data.labels ?? defaultUserAdminLabels
|
|
619
828
|
|
|
829
|
+
const { submit: submitThen, result: actionResult } = useSubmitThen(fetcher)
|
|
830
|
+
const busy = fetcher.state !== "idle"
|
|
620
831
|
const [pattern, setPattern] = useState("")
|
|
621
832
|
const [type, setType] = useState<"email" | "domain">("email")
|
|
622
833
|
const [description, setDescription] = useState("")
|
|
@@ -624,23 +835,26 @@ export function AllowlistView() {
|
|
|
624
835
|
const handleAddEntry = (e: React.FormEvent) => {
|
|
625
836
|
e.preventDefault()
|
|
626
837
|
if (!pattern) return
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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))
|
|
842
|
+
})
|
|
630
843
|
}
|
|
631
844
|
|
|
632
845
|
const handleRemoveEntry = (id: number) => {
|
|
633
|
-
|
|
846
|
+
submitThen({ intent: "removeAllowlistEntry", entryId: String(id) })
|
|
634
847
|
}
|
|
635
848
|
|
|
636
849
|
/** グループ許可は別の表に住むため、別の intent で消す。 */
|
|
637
850
|
const handleRemoveGroupEntry = (id: number) => {
|
|
638
|
-
|
|
851
|
+
submitThen({ intent: "removeAllowlistGroup", entryId: String(id) })
|
|
639
852
|
}
|
|
640
853
|
|
|
641
854
|
return (
|
|
642
855
|
<section data-testid="allowlist-view-section" style={{ display: "flex", flexDirection: "column", gap: "1.2rem" }}>
|
|
643
|
-
<
|
|
856
|
+
<h2 style={heading}>{labels.allowlistView.title}</h2>
|
|
857
|
+
<ActionError result={actionResult} />
|
|
644
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" }}>
|
|
645
859
|
<div style={{ flex: 2, minWidth: "220px" }}>
|
|
646
860
|
<label htmlFor="allowlist-pattern-input-field" style={{ display: "block", fontSize: "0.78rem", color: "#475569", marginBottom: "4px" }}>
|
|
@@ -683,7 +897,7 @@ export function AllowlistView() {
|
|
|
683
897
|
style={{ width: "100%", padding: "6px 10px", borderRadius: "6px", border: "1px solid #cbd5e1", fontSize: "0.85rem", boxSizing: "border-box" }}
|
|
684
898
|
/>
|
|
685
899
|
</div>
|
|
686
|
-
<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" }}>
|
|
900
|
+
<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" }}>
|
|
687
901
|
{labels.allowlistView.addEntry}
|
|
688
902
|
</button>
|
|
689
903
|
</form>
|
|
@@ -692,22 +906,22 @@ export function AllowlistView() {
|
|
|
692
906
|
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.85rem", textAlign: "left" }}>
|
|
693
907
|
<thead>
|
|
694
908
|
<tr style={{ backgroundColor: "#f8fafc", borderBottom: "1px solid #e2e8f0", color: "#475569" }}>
|
|
695
|
-
<th style={{ padding: "10px 14px" }}>
|
|
909
|
+
<th style={{ padding: "10px 14px" }}>{labels.common.id}</th>
|
|
696
910
|
<th style={{ padding: "10px 14px" }}>{labels.allowlistView.target}</th>
|
|
697
911
|
<th style={{ padding: "10px 14px" }}>{labels.allowlistView.type}</th>
|
|
698
912
|
<th style={{ padding: "10px 14px" }}>{labels.allowlistView.description}</th>
|
|
699
|
-
<th style={{ padding: "10px 14px", textAlign: "right" }}>{labels.
|
|
913
|
+
<th style={{ padding: "10px 14px", textAlign: "right" }}>{labels.common.actions}</th>
|
|
700
914
|
</tr>
|
|
701
915
|
</thead>
|
|
702
916
|
<tbody>
|
|
703
917
|
{data.allowlist.map((item) => (
|
|
704
|
-
<tr key={`${item.type}-${item.id}`} style={{ borderBottom: "1px solid #f1f5f9" }} data-testid={`allowlist-row-${item.id}`} data-allowlist-type={item.type}>
|
|
705
|
-
<td style={{ padding: "10px 14px", color: "#
|
|
918
|
+
<tr key={`${item.type}-${item.id}`} style={{ borderBottom: "1px solid #f1f5f9" }} data-testid={`allowlist-row-${item.type}-${item.id}`} data-allowlist-type={item.type}>
|
|
919
|
+
<td style={{ padding: "10px 14px", color: "#475569" }}>{fill(labels.common.idTemplate, { id: item.id })}</td>
|
|
706
920
|
<td style={{ padding: "10px 14px", fontWeight: 600, color: "#0f172a" }}>
|
|
707
921
|
{/* パターン許可とグループ許可では正準値が違う。グループはアドレスではなく
|
|
708
922
|
グループそのものが対象なので、名前とキーを見せる */}
|
|
709
923
|
{item.type === "group" ? (
|
|
710
|
-
<span data-testid={`allowlist-group-${item.id}`}>
|
|
924
|
+
<span data-testid={`allowlist-group-${item.type}-${item.id}`}>
|
|
711
925
|
{item.groupName}
|
|
712
926
|
<span style={{ marginLeft: "6px", fontFamily: "monospace", fontSize: "0.78rem", color: "#64748b" }}>{item.groupKey}</span>
|
|
713
927
|
</span>
|
|
@@ -719,10 +933,10 @@ export function AllowlistView() {
|
|
|
719
933
|
<span style={{ fontSize: "0.75rem", padding: "2px 8px", borderRadius: "10px", ...allowlistBadgeStyle(item.type), fontWeight: 600 }}>{allowlistTypeLabel(item.type, labels)}</span>
|
|
720
934
|
</td>
|
|
721
935
|
<td style={{ padding: "10px 14px", color: "#475569" }}>
|
|
722
|
-
{item.description ||
|
|
936
|
+
{item.description || labels.common.none}
|
|
723
937
|
{/* 配備全体が所有する行は、どのテナントの管理者でも編集できない */}
|
|
724
938
|
{item.owner.kind === "deployment" && (
|
|
725
|
-
<span data-testid={`allowlist-owner-${item.id}`} style={{ marginLeft: "8px", fontSize: "0.72rem", color: "#
|
|
939
|
+
<span data-testid={`allowlist-owner-${item.type}-${item.id}`} style={{ marginLeft: "8px", fontSize: "0.72rem", color: "#475569" }}>
|
|
726
940
|
{labels.allowlistView.deploymentOwner}
|
|
727
941
|
</span>
|
|
728
942
|
)}
|
|
@@ -731,16 +945,24 @@ export function AllowlistView() {
|
|
|
731
945
|
{/* 変更不能な供給元 (環境変数など) は削除ボタンを出さない。無効化ではなく
|
|
732
946
|
非描画にするのは、押せないボタンが押せる操作と見分けられないため */}
|
|
733
947
|
{item.readOnly ? (
|
|
734
|
-
<span data-testid={`readonly-allowlist-${item.id}`} style={{ fontSize: "0.75rem", color: "#
|
|
735
|
-
{labels.
|
|
948
|
+
<span data-testid={`readonly-allowlist-${item.type}-${item.id}`} style={{ fontSize: "0.75rem", color: "#475569" }}>
|
|
949
|
+
{labels.common.readOnly}
|
|
736
950
|
</span>
|
|
737
951
|
) : (
|
|
738
952
|
<button
|
|
739
953
|
type="button"
|
|
740
|
-
onClick={() =>
|
|
741
|
-
|
|
954
|
+
onClick={() => {
|
|
955
|
+
// これでしかサインインできない人はログインできなくなる。取り消せない
|
|
956
|
+
if (!window.confirm(labels.allowlistView.confirmDelete)) return
|
|
957
|
+
if (item.type === "group") handleRemoveGroupEntry(item.id)
|
|
958
|
+
else handleRemoveEntry(item.id)
|
|
959
|
+
}}
|
|
960
|
+
disabled={busy}
|
|
961
|
+
aria-busy={busy}
|
|
962
|
+
aria-label={fill(labels.allowlistView.deleteAria, { target: item.type === "group" ? item.groupName : item.pattern })}
|
|
963
|
+
data-testid={`remove-allowlist-btn-${item.type}-${item.id}`}
|
|
742
964
|
style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #fee2e2", backgroundColor: "#fff5f5", color: "#b91c1c", fontSize: "0.78rem", cursor: "pointer" }}>
|
|
743
|
-
{labels.
|
|
965
|
+
{labels.common.delete}
|
|
744
966
|
</button>
|
|
745
967
|
)}
|
|
746
968
|
</td>
|
|
@@ -748,7 +970,7 @@ export function AllowlistView() {
|
|
|
748
970
|
))}
|
|
749
971
|
{data.allowlist.length === 0 && (
|
|
750
972
|
<tr>
|
|
751
|
-
<td colSpan={5} style={{ padding: "2rem", textAlign: "center", color: "#
|
|
973
|
+
<td colSpan={5} style={{ padding: "2rem", textAlign: "center", color: "#475569" }}>
|
|
752
974
|
{labels.allowlistView.empty}
|
|
753
975
|
</td>
|
|
754
976
|
</tr>
|
|
@@ -760,11 +982,258 @@ export function AllowlistView() {
|
|
|
760
982
|
)
|
|
761
983
|
}
|
|
762
984
|
|
|
985
|
+
/**
|
|
986
|
+
* View for the upstream directory: what is linked, how fresh it is, and the controls that change it.
|
|
987
|
+
* 上流ディレクトリのビュー。何が連携され、どれだけ新しく、それを変える操作を提供する。
|
|
988
|
+
*
|
|
989
|
+
* このタブが無いと、連携の作成・解除・一時停止・即時同期・グループ単位のサインイン許可という
|
|
990
|
+
* 5 つの操作にどこからも到達できない。実装されているのに押せる場所が無い状態は、実装が無いのと
|
|
991
|
+
* 変わらないうえ、「許可を消せるが作れない」という片肺の運用を強いる。
|
|
992
|
+
*/
|
|
993
|
+
export function DirectoryView() {
|
|
994
|
+
const data = useLoaderData() as UserAdminLoaderData
|
|
995
|
+
const fetcher = useFetcher()
|
|
996
|
+
const { submit: submitThen, result: actionResult } = useSubmitThen(fetcher)
|
|
997
|
+
const labels = data.labels ?? defaultUserAdminLabels
|
|
998
|
+
const L = labels.directoryView
|
|
999
|
+
|
|
1000
|
+
const linkable = data.groups.filter((g) => !g.externalLink)
|
|
1001
|
+
const [groupId, setGroupId] = useState("")
|
|
1002
|
+
const [externalKey, setExternalKey] = useState("")
|
|
1003
|
+
const [membershipMode, setMembershipMode] = useState<AuthDirectoryMembershipMode>("direct")
|
|
1004
|
+
|
|
1005
|
+
const linked = data.groups.filter((g) => g.externalLink)
|
|
1006
|
+
const allowedGroupIds = new Set(data.allowlist.filter((e) => e.type === "group").map((e) => e.groupId))
|
|
1007
|
+
const allowlistEntryOf = (id: number) => data.allowlist.find((e) => e.type === "group" && e.groupId === id)
|
|
1008
|
+
const busy = fetcher.state !== "idle"
|
|
1009
|
+
// 「要求しました」を出す対象。押しても何も変わらないと、何度も押される
|
|
1010
|
+
const [queuedGroupId, setQueuedGroupId] = useState<number | null>(null)
|
|
1011
|
+
// 供給元が判らないまま欄を出すと、送信は必ずサーバー側の内部文言で失敗する
|
|
1012
|
+
const canLink = data.directoryStatus.isEnabled && data.directoryStatus.provider !== ""
|
|
1013
|
+
|
|
1014
|
+
/** 連携を作る。上流キーは運用者が知っている値 (グループアドレス) で受け取る。 */
|
|
1015
|
+
const handleLink = (e: React.FormEvent) => {
|
|
1016
|
+
e.preventDefault()
|
|
1017
|
+
if (!(groupId && externalKey)) return
|
|
1018
|
+
// 供給元は配備の設定から来る。ここに特定の値を書くと、別の上流を使う配備で作った
|
|
1019
|
+
// リンクが同期側で恒久的に拒否される
|
|
1020
|
+
const sentKey = externalKey
|
|
1021
|
+
submitThen({ intent: "linkDirectoryGroup", groupId, provider: data.directoryStatus.provider, externalKey: sentKey, membershipMode }, () => {
|
|
1022
|
+
setExternalKey((current) => (current === sentKey ? "" : current))
|
|
1023
|
+
// 連携済みになった行は候補から消える。選択を残すと、表示は空欄なのに前の
|
|
1024
|
+
// グループへ送信される
|
|
1025
|
+
setGroupId("")
|
|
1026
|
+
})
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
return (
|
|
1030
|
+
<section data-testid="directory-view-section" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
|
1031
|
+
<h2 style={heading}>{L.title}</h2>
|
|
1032
|
+
<ActionError result={actionResult} />
|
|
1033
|
+
|
|
1034
|
+
{!canLink && (
|
|
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" }}>
|
|
1036
|
+
{L.notWiredNotice}
|
|
1037
|
+
</p>
|
|
1038
|
+
)}
|
|
1039
|
+
|
|
1040
|
+
{canLink && linkable.length === 0 && (
|
|
1041
|
+
<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" }}>
|
|
1042
|
+
{L.nothingToLink}
|
|
1043
|
+
</p>
|
|
1044
|
+
)}
|
|
1045
|
+
|
|
1046
|
+
{canLink && linkable.length > 0 && (
|
|
1047
|
+
<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" }}>
|
|
1048
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
|
1049
|
+
<label htmlFor="directory-group-select-input" style={fieldLabel}>
|
|
1050
|
+
{L.selectGroup}
|
|
1051
|
+
</label>
|
|
1052
|
+
<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" }}>
|
|
1053
|
+
<option value="">{L.selectGroup}</option>
|
|
1054
|
+
{linkable.map((g) => (
|
|
1055
|
+
<option key={g.id} value={String(g.id)}>
|
|
1056
|
+
{fill(L.groupOptionTemplate, { name: g.name, key: g.groupKey })}
|
|
1057
|
+
</option>
|
|
1058
|
+
))}
|
|
1059
|
+
</select>
|
|
1060
|
+
</div>
|
|
1061
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "0.25rem", flex: 1, minWidth: "220px" }}>
|
|
1062
|
+
<label htmlFor="directory-external-key-input" style={fieldLabel}>
|
|
1063
|
+
{L.externalKey}
|
|
1064
|
+
</label>
|
|
1065
|
+
<input
|
|
1066
|
+
id="directory-external-key-input"
|
|
1067
|
+
type="email"
|
|
1068
|
+
required
|
|
1069
|
+
data-testid="directory-external-key"
|
|
1070
|
+
placeholder={L.externalKeyPlaceholder}
|
|
1071
|
+
value={externalKey}
|
|
1072
|
+
onChange={(e) => setExternalKey(e.target.value)}
|
|
1073
|
+
style={{ padding: "0.5rem", borderRadius: "6px", border: "1px solid #cbd5e1" }}
|
|
1074
|
+
/>
|
|
1075
|
+
</div>
|
|
1076
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
|
1077
|
+
<label htmlFor="directory-membership-mode-input" style={fieldLabel}>
|
|
1078
|
+
{L.membershipMode}
|
|
1079
|
+
</label>
|
|
1080
|
+
<select
|
|
1081
|
+
id="directory-membership-mode-input"
|
|
1082
|
+
data-testid="directory-membership-mode"
|
|
1083
|
+
value={membershipMode}
|
|
1084
|
+
onChange={(e) => setMembershipMode(e.target.value as AuthDirectoryMembershipMode)}
|
|
1085
|
+
style={{ padding: "0.5rem", borderRadius: "6px", border: "1px solid #cbd5e1" }}>
|
|
1086
|
+
<option value="direct">{L.directMode}</option>
|
|
1087
|
+
<option value="transitive">{L.transitiveMode}</option>
|
|
1088
|
+
</select>
|
|
1089
|
+
</div>
|
|
1090
|
+
<button
|
|
1091
|
+
type="submit"
|
|
1092
|
+
data-testid="directory-link-submit"
|
|
1093
|
+
disabled={!(groupId && externalKey) || busy}
|
|
1094
|
+
aria-busy={busy}
|
|
1095
|
+
style={{ padding: "0.5rem 1rem", borderRadius: "6px", border: "none", backgroundColor: groupId && externalKey && !busy ? "#2563eb" : "#94a3b8", color: "#fff", fontWeight: 600 }}>
|
|
1096
|
+
{L.link}
|
|
1097
|
+
</button>
|
|
1098
|
+
</form>
|
|
1099
|
+
)}
|
|
1100
|
+
|
|
1101
|
+
{linked.length === 0 ? (
|
|
1102
|
+
<div data-testid="directory-empty" style={{ padding: "2rem", textAlign: "center", color: "#64748b", backgroundColor: "#ffffff", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
|
|
1103
|
+
{L.empty}
|
|
1104
|
+
</div>
|
|
1105
|
+
) : (
|
|
1106
|
+
<table data-testid="directory-table" style={{ width: "100%", borderCollapse: "collapse", backgroundColor: "#ffffff", borderRadius: "8px", overflow: "hidden", border: "1px solid #e2e8f0" }}>
|
|
1107
|
+
<thead>
|
|
1108
|
+
<tr style={{ backgroundColor: "#f8fafc", textAlign: "left" }}>
|
|
1109
|
+
<th style={cell}>{labels.groupsView.name}</th>
|
|
1110
|
+
<th style={cell}>{L.externalKey}</th>
|
|
1111
|
+
<th style={cell}>{L.membershipMode}</th>
|
|
1112
|
+
<th style={cell}>{L.lastSynced}</th>
|
|
1113
|
+
<th style={cell}>{labels.common.status}</th>
|
|
1114
|
+
<th style={cell}>{labels.common.actions}</th>
|
|
1115
|
+
</tr>
|
|
1116
|
+
</thead>
|
|
1117
|
+
<tbody>
|
|
1118
|
+
{linked.map((g) => {
|
|
1119
|
+
const link = g.externalLink as NonNullable<AuthGroup["externalLink"]>
|
|
1120
|
+
const allowed = allowedGroupIds.has(g.id)
|
|
1121
|
+
return (
|
|
1122
|
+
<tr key={g.id} data-testid={`directory-row-${g.id}`} data-sync-enabled={String(link.isSyncEnabled)} style={{ borderTop: "1px solid #e2e8f0" }}>
|
|
1123
|
+
<td style={cell}>
|
|
1124
|
+
{g.name}
|
|
1125
|
+
<div style={{ fontSize: "0.75rem", color: "#64748b" }}>{g.groupKey}</div>
|
|
1126
|
+
</td>
|
|
1127
|
+
<td style={cell} data-testid={`directory-external-${g.id}`}>
|
|
1128
|
+
{link.externalKey}
|
|
1129
|
+
</td>
|
|
1130
|
+
<td style={cell}>{link.membershipMode === "direct" ? L.directMode : L.transitiveMode}</td>
|
|
1131
|
+
<td style={cell} data-testid={`directory-synced-${g.id}`}>
|
|
1132
|
+
{link.syncedAt ? formatDateTime(link.syncedAt, labels) : L.neverSynced}
|
|
1133
|
+
{link.unresolvedCount > 0 ? (
|
|
1134
|
+
<div style={{ fontSize: "0.75rem", color: "#b45309" }} data-testid={`directory-unresolved-${g.id}`}>
|
|
1135
|
+
{fill(L.unresolvedTemplate, { count: link.unresolvedCount })}
|
|
1136
|
+
</div>
|
|
1137
|
+
) : null}
|
|
1138
|
+
</td>
|
|
1139
|
+
<td style={cell}>
|
|
1140
|
+
<span
|
|
1141
|
+
data-testid={`directory-status-${g.id}`}
|
|
1142
|
+
style={{ fontSize: "0.75rem", padding: "2px 8px", borderRadius: "10px", backgroundColor: link.isSyncEnabled ? "#dcfce7" : "#fee2e2", color: link.isSyncEnabled ? "#166534" : "#b91c1c", fontWeight: 600 }}>
|
|
1143
|
+
{link.isSyncEnabled ? L.syncEnabled : L.syncPaused}
|
|
1144
|
+
</span>
|
|
1145
|
+
{link.syncError ? (
|
|
1146
|
+
<div style={{ fontSize: "0.75rem", color: "#b91c1c", marginTop: "0.25rem" }} data-testid={`directory-error-${g.id}`}>
|
|
1147
|
+
{link.syncError}
|
|
1148
|
+
</div>
|
|
1149
|
+
) : null}
|
|
1150
|
+
</td>
|
|
1151
|
+
{/* td 自体を flex にすると表セルの役割が失われ、列と見出しの対応が切れる */}
|
|
1152
|
+
<td style={cell}>
|
|
1153
|
+
<div style={{ display: "flex", gap: "0.35rem", flexWrap: "wrap" }}>
|
|
1154
|
+
<button
|
|
1155
|
+
type="button"
|
|
1156
|
+
data-testid={`directory-sync-now-${g.id}`}
|
|
1157
|
+
disabled={busy}
|
|
1158
|
+
aria-busy={busy}
|
|
1159
|
+
aria-label={fill(queuedGroupId === g.id ? L.syncQueuedAria : L.syncNowAria, { group: g.name })}
|
|
1160
|
+
onClick={() => submitThen({ intent: "requestDirectorySync", groupId: String(g.id) }, () => setQueuedGroupId(g.id))}
|
|
1161
|
+
style={smallButton}>
|
|
1162
|
+
{queuedGroupId === g.id ? L.syncQueued : L.syncNow}
|
|
1163
|
+
</button>
|
|
1164
|
+
<button
|
|
1165
|
+
type="button"
|
|
1166
|
+
data-testid={`directory-toggle-sync-${g.id}`}
|
|
1167
|
+
disabled={busy}
|
|
1168
|
+
aria-busy={busy}
|
|
1169
|
+
aria-label={fill(link.isSyncEnabled ? L.pauseAria : L.resumeAria, { group: g.name })}
|
|
1170
|
+
onClick={() => {
|
|
1171
|
+
setQueuedGroupId(null)
|
|
1172
|
+
submitThen({ intent: "setDirectorySyncEnabled", groupId: String(g.id), isSyncEnabled: String(!link.isSyncEnabled) })
|
|
1173
|
+
}}
|
|
1174
|
+
style={smallButton}>
|
|
1175
|
+
{link.isSyncEnabled ? L.pause : L.resume}
|
|
1176
|
+
</button>
|
|
1177
|
+
<button
|
|
1178
|
+
type="button"
|
|
1179
|
+
data-testid={`directory-allow-${g.id}`}
|
|
1180
|
+
disabled={busy}
|
|
1181
|
+
aria-busy={busy}
|
|
1182
|
+
aria-label={fill(allowed ? L.disallowGroupAria : L.allowGroupAria, { group: g.name })}
|
|
1183
|
+
onClick={() => {
|
|
1184
|
+
const entry = allowlistEntryOf(g.id)
|
|
1185
|
+
if (allowed && entry) {
|
|
1186
|
+
// 取り消しはサインインを止める。追加は元に戻すだけなので確認しない
|
|
1187
|
+
if (!window.confirm(L.confirmDisallow)) return
|
|
1188
|
+
submitThen({ intent: "removeAllowlistGroup", entryId: String(entry.id) })
|
|
1189
|
+
} else {
|
|
1190
|
+
submitThen({ intent: "addAllowlistGroup", groupId: String(g.id) })
|
|
1191
|
+
}
|
|
1192
|
+
}}
|
|
1193
|
+
style={smallButton}>
|
|
1194
|
+
{allowed ? L.disallowGroup : L.allowGroup}
|
|
1195
|
+
</button>
|
|
1196
|
+
<button
|
|
1197
|
+
type="button"
|
|
1198
|
+
data-testid={`directory-unlink-${g.id}`}
|
|
1199
|
+
disabled={busy}
|
|
1200
|
+
aria-busy={busy}
|
|
1201
|
+
aria-label={fill(L.unlinkAria, { group: g.name })}
|
|
1202
|
+
onClick={() => {
|
|
1203
|
+
// 台帳とグループ単位の許可が失われる。取り消せないので必ず確認する
|
|
1204
|
+
if (!window.confirm(L.confirmUnlink)) return
|
|
1205
|
+
submitThen({ intent: "unlinkDirectoryGroup", groupId: String(g.id) })
|
|
1206
|
+
}}
|
|
1207
|
+
style={{ ...smallButton, color: "#b91c1c", borderColor: "#fecaca" }}>
|
|
1208
|
+
{L.unlink}
|
|
1209
|
+
</button>
|
|
1210
|
+
</div>
|
|
1211
|
+
</td>
|
|
1212
|
+
</tr>
|
|
1213
|
+
)
|
|
1214
|
+
})}
|
|
1215
|
+
</tbody>
|
|
1216
|
+
</table>
|
|
1217
|
+
)}
|
|
1218
|
+
</section>
|
|
1219
|
+
)
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/** フォーム項目の見出し。プレースホルダは入力すると消えるため名前にならない。 */
|
|
1223
|
+
const fieldLabel: React.CSSProperties = { fontSize: "0.78rem", color: "#475569", fontWeight: 600 }
|
|
1224
|
+
/** 各ビューの見出し。段階を飛ばさないよう h2 に固定する。 */
|
|
1225
|
+
const heading: React.CSSProperties = { margin: 0, fontSize: "1.05rem", fontWeight: 700, color: "#0f172a" }
|
|
1226
|
+
/** 表セルの共通スタイル。 */
|
|
1227
|
+
const cell: React.CSSProperties = { padding: "0.6rem 0.75rem", fontSize: "0.85rem", verticalAlign: "top" }
|
|
1228
|
+
/** 行内の小さな操作ボタン。 */
|
|
1229
|
+
const smallButton: React.CSSProperties = { padding: "0.25rem 0.6rem", fontSize: "0.75rem", borderRadius: "6px", border: "1px solid #cbd5e1", backgroundColor: "#fff", cursor: "pointer" }
|
|
1230
|
+
|
|
763
1231
|
const SEGMENT_VIEWS: Record<string, () => React.ReactElement> = {
|
|
764
1232
|
users: UsersView,
|
|
765
1233
|
groups: GroupsView,
|
|
766
1234
|
group_members: GroupMembersView,
|
|
767
1235
|
allowlist: AllowlistView,
|
|
1236
|
+
directory: DirectoryView,
|
|
768
1237
|
}
|
|
769
1238
|
|
|
770
1239
|
export type AuthUserAdminAppViewProps = UserAdminShellProps
|
|
@@ -776,7 +1245,8 @@ export type AuthUserAdminAppViewProps = UserAdminShellProps
|
|
|
776
1245
|
export function AuthUserAdminAppView({ renderHeader }: AuthUserAdminAppViewProps = {}) {
|
|
777
1246
|
const data = useLoaderData() as UserAdminLoaderData | undefined
|
|
778
1247
|
const segment = data?.segment ?? "users"
|
|
779
|
-
|
|
1248
|
+
// 素のオブジェクト参照だと `segment="constructor"` が真値を返し、画面が描けなくなる
|
|
1249
|
+
const View = Object.hasOwn(SEGMENT_VIEWS, segment) ? SEGMENT_VIEWS[segment] : UsersView
|
|
780
1250
|
|
|
781
1251
|
return (
|
|
782
1252
|
<UserAdminShell renderHeader={renderHeader}>
|