@aiquants/auth-react-router 0.11.0 → 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 +161 -26
- package/dist/admin.d.ts +161 -26
- 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 +33 -0
- package/src/admin/labels.ts +301 -91
- package/src/admin/server.ts +59 -23
- package/src/admin/ui.tsx +68 -8
- package/src/admin/views.tsx +532 -124
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,19 +27,107 @@ export type UserAdminLoaderData = {
|
|
|
26
27
|
* ロックアウト理由・重複パターン・入力不正などの文言は action が `{ok:false,error}` で返す。
|
|
27
28
|
* 表示しなければ操作が黙って失敗したように見えるため、各ビューの先頭に必ず置く。
|
|
28
29
|
*/
|
|
29
|
-
function ActionError({ fetcher }: { fetcher: { data?: unknown } }) {
|
|
30
|
+
function ActionError({ fetcher, id }: { fetcher: { data?: unknown }; id?: string }) {
|
|
30
31
|
const result = fetcher.data 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
|
+
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
|
+
|
|
39
127
|
export function UsersView() {
|
|
40
128
|
const data = useLoaderData() as UserAdminLoaderData
|
|
41
129
|
const fetcher = useFetcher()
|
|
130
|
+
const submitThen = useSubmitThen(fetcher)
|
|
42
131
|
const labels = data.labels ?? defaultUserAdminLabels
|
|
43
132
|
|
|
44
133
|
const [searchQuery, setSearchQuery] = useState("")
|
|
@@ -47,35 +136,43 @@ export function UsersView() {
|
|
|
47
136
|
|
|
48
137
|
const [newDisplayName, setNewDisplayName] = useState("")
|
|
49
138
|
const [newEmail, setNewEmail] = useState("")
|
|
139
|
+
const createUserRef = useModalBehavior(isCreateOpen, () => setIsCreateOpen(false))
|
|
140
|
+
const editUserRef = useModalBehavior(editingUser !== null, () => setEditingUser(null))
|
|
50
141
|
|
|
142
|
+
const busy = fetcher.state !== "idle"
|
|
51
143
|
const filteredUsers = data.users.filter((u) => u.displayName.toLowerCase().includes(searchQuery.toLowerCase()) || u.email.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
52
144
|
|
|
53
145
|
const handleToggleActive = (user: AuthUser) => {
|
|
54
|
-
|
|
146
|
+
// 無効化はサインインを止める。有効化は元に戻すだけなので確認しない
|
|
147
|
+
if (user.isActive && !window.confirm(labels.usersView.confirmDeactivate)) return
|
|
148
|
+
submitThen({ intent: "toggleUserActive", userId: String(user.id), isActive: String(!user.isActive) })
|
|
55
149
|
}
|
|
56
150
|
|
|
57
151
|
const handleCreateUser = (e: React.FormEvent) => {
|
|
58
152
|
e.preventDefault()
|
|
59
153
|
if (!(newDisplayName && newEmail)) return
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
154
|
+
submitThen({ intent: "createUser", displayName: newDisplayName, email: newEmail }, () => {
|
|
155
|
+
setNewDisplayName("")
|
|
156
|
+
setNewEmail("")
|
|
157
|
+
setIsCreateOpen(false)
|
|
158
|
+
})
|
|
64
159
|
}
|
|
65
160
|
|
|
66
161
|
const handleUpdateUser = (e: React.FormEvent) => {
|
|
67
162
|
e.preventDefault()
|
|
68
163
|
if (!editingUser) return
|
|
69
|
-
|
|
70
|
-
setEditingUser(null)
|
|
164
|
+
submitThen({ intent: "updateUser", userId: String(editingUser.id), displayName: editingUser.displayName, email: editingUser.email }, () => setEditingUser(null))
|
|
71
165
|
}
|
|
72
166
|
|
|
73
167
|
return (
|
|
74
168
|
<section data-testid="users-view-section" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
|
75
|
-
<
|
|
169
|
+
<h2 style={heading}>{labels.usersView.title}</h2>
|
|
170
|
+
{/* モーダルが開いている間はモーダル内に描く。両方描くと同じ hook が 2 つになる */}
|
|
171
|
+
{!(isCreateOpen || editingUser) && <ActionError fetcher={fetcher} />}
|
|
76
172
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", backgroundColor: "#ffffff", padding: "1rem", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
|
|
77
173
|
<input
|
|
78
174
|
type="text"
|
|
175
|
+
aria-label={labels.usersView.searchLabel}
|
|
79
176
|
placeholder={labels.usersView.searchPlaceholder}
|
|
80
177
|
value={searchQuery}
|
|
81
178
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
@@ -91,17 +188,17 @@ export function UsersView() {
|
|
|
91
188
|
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.85rem", textAlign: "left" }}>
|
|
92
189
|
<thead>
|
|
93
190
|
<tr style={{ backgroundColor: "#f8fafc", borderBottom: "1px solid #e2e8f0", color: "#475569" }}>
|
|
94
|
-
<th style={{ padding: "10px 14px" }}>{labels.
|
|
191
|
+
<th style={{ padding: "10px 14px" }}>{labels.common.id}</th>
|
|
95
192
|
<th style={{ padding: "10px 14px" }}>{labels.usersView.displayName}</th>
|
|
96
193
|
<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.
|
|
194
|
+
<th style={{ padding: "10px 14px" }}>{labels.common.status}</th>
|
|
195
|
+
<th style={{ padding: "10px 14px", textAlign: "right" }}>{labels.common.actions}</th>
|
|
99
196
|
</tr>
|
|
100
197
|
</thead>
|
|
101
198
|
<tbody>
|
|
102
199
|
{filteredUsers.map((u) => (
|
|
103
200
|
<tr key={u.id} style={{ borderBottom: "1px solid #f1f5f9" }} data-testid={`user-row-${u.id}`}>
|
|
104
|
-
<td style={{ padding: "10px 14px", color: "#
|
|
201
|
+
<td style={{ padding: "10px 14px", color: "#475569" }}>{fill(labels.common.idTemplate, { id: u.id })}</td>
|
|
105
202
|
<td style={{ padding: "10px 14px", fontWeight: 600, color: "#0f172a" }}>{u.displayName}</td>
|
|
106
203
|
<td style={{ padding: "10px 14px", color: "#475569" }}>{u.email}</td>
|
|
107
204
|
<td style={{ padding: "10px 14px" }}>
|
|
@@ -113,15 +210,19 @@ export function UsersView() {
|
|
|
113
210
|
<button
|
|
114
211
|
type="button"
|
|
115
212
|
onClick={() => setEditingUser(u)}
|
|
213
|
+
aria-label={fill(labels.usersView.editUserAria, { user: u.displayName })}
|
|
116
214
|
data-testid={`edit-user-btn-${u.id}`}
|
|
117
215
|
style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", fontSize: "0.78rem", cursor: "pointer", marginRight: "6px" }}>
|
|
118
|
-
{labels.
|
|
216
|
+
{labels.common.edit}
|
|
119
217
|
</button>
|
|
120
218
|
<button
|
|
121
219
|
type="button"
|
|
122
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 })}
|
|
123
224
|
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" }}>
|
|
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" }}>
|
|
125
226
|
{u.isActive ? labels.usersView.deactivate : labels.usersView.activate}
|
|
126
227
|
</button>
|
|
127
228
|
</td>
|
|
@@ -129,7 +230,7 @@ export function UsersView() {
|
|
|
129
230
|
))}
|
|
130
231
|
{filteredUsers.length === 0 && (
|
|
131
232
|
<tr>
|
|
132
|
-
<td colSpan={5} style={{ padding: "2rem", textAlign: "center", color: "#
|
|
233
|
+
<td colSpan={5} style={{ padding: "2rem", textAlign: "center", color: "#475569" }}>
|
|
133
234
|
{labels.usersView.empty}
|
|
134
235
|
</td>
|
|
135
236
|
</tr>
|
|
@@ -140,9 +241,20 @@ export function UsersView() {
|
|
|
140
241
|
|
|
141
242
|
{/* Create Modal */}
|
|
142
243
|
{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
|
-
|
|
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" />
|
|
146
258
|
<div style={{ marginBottom: "1rem" }}>
|
|
147
259
|
<label htmlFor="create-user-display-name-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
|
|
148
260
|
{labels.usersView.displayName}
|
|
@@ -175,7 +287,7 @@ export function UsersView() {
|
|
|
175
287
|
<button type="button" onClick={() => setIsCreateOpen(false)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
|
|
176
288
|
キャンセル
|
|
177
289
|
</button>
|
|
178
|
-
<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" }}>
|
|
179
291
|
作成
|
|
180
292
|
</button>
|
|
181
293
|
</div>
|
|
@@ -185,9 +297,20 @@ export function UsersView() {
|
|
|
185
297
|
|
|
186
298
|
{/* Edit Modal */}
|
|
187
299
|
{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
|
-
|
|
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" />
|
|
191
314
|
<div style={{ marginBottom: "1rem" }}>
|
|
192
315
|
<label htmlFor="edit-user-display-name-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
|
|
193
316
|
{labels.usersView.displayName}
|
|
@@ -218,10 +341,10 @@ export function UsersView() {
|
|
|
218
341
|
</div>
|
|
219
342
|
<div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
|
|
220
343
|
<button type="button" onClick={() => setEditingUser(null)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
|
|
221
|
-
{labels.
|
|
344
|
+
{labels.common.cancel}
|
|
222
345
|
</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.
|
|
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}
|
|
225
348
|
</button>
|
|
226
349
|
</div>
|
|
227
350
|
</form>
|
|
@@ -235,77 +358,55 @@ export function UsersView() {
|
|
|
235
358
|
* View for managing user groups (create, edit, delete).
|
|
236
359
|
* ユーザーグループ管理ビュー (作成・編集・削除)。
|
|
237
360
|
*/
|
|
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
361
|
export function GroupsView() {
|
|
266
362
|
const data = useLoaderData() as UserAdminLoaderData
|
|
267
363
|
const fetcher = useFetcher()
|
|
364
|
+
const submitThen = useSubmitThen(fetcher)
|
|
268
365
|
const labels = data.labels ?? defaultUserAdminLabels
|
|
269
366
|
|
|
270
367
|
const [searchQuery, setSearchQuery] = useState("")
|
|
271
368
|
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
|
272
369
|
const [editingGroup, setEditingGroup] = useState<AuthGroup | null>(null)
|
|
273
370
|
|
|
371
|
+
const busy = fetcher.state !== "idle"
|
|
274
372
|
const [newGroupKey, setNewGroupKey] = useState("")
|
|
275
373
|
const [newName, setNewName] = useState("")
|
|
276
374
|
const [newDescription, setNewDescription] = useState("")
|
|
375
|
+
const createGroupRef = useModalBehavior(isCreateOpen, () => setIsCreateOpen(false))
|
|
376
|
+
const editGroupRef = useModalBehavior(editingGroup !== null, () => setEditingGroup(null))
|
|
277
377
|
|
|
278
378
|
const filteredGroups = data.groups.filter((g) => g.name.toLowerCase().includes(searchQuery.toLowerCase()) || g.groupKey.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
279
379
|
|
|
280
380
|
const handleCreateGroup = (e: React.FormEvent) => {
|
|
281
381
|
e.preventDefault()
|
|
282
382
|
if (!(newGroupKey && newName)) return
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
383
|
+
submitThen({ intent: "createGroup", groupKey: newGroupKey, name: newName, description: newDescription }, () => {
|
|
384
|
+
setNewGroupKey("")
|
|
385
|
+
setNewName("")
|
|
386
|
+
setNewDescription("")
|
|
387
|
+
setIsCreateOpen(false)
|
|
388
|
+
})
|
|
288
389
|
}
|
|
289
390
|
|
|
290
391
|
const handleUpdateGroup = (e: React.FormEvent) => {
|
|
291
392
|
e.preventDefault()
|
|
292
393
|
if (!editingGroup) return
|
|
293
|
-
|
|
294
|
-
setEditingGroup(null)
|
|
394
|
+
submitThen({ intent: "updateGroup", groupId: String(editingGroup.id), name: editingGroup.name, description: editingGroup.description ?? "" }, () => setEditingGroup(null))
|
|
295
395
|
}
|
|
296
396
|
|
|
297
397
|
const handleDeleteGroup = (groupId: number) => {
|
|
298
398
|
if (!window.confirm(labels.groupsView.confirmDelete)) return
|
|
299
|
-
|
|
399
|
+
submitThen({ intent: "deleteGroup", groupId: String(groupId) })
|
|
300
400
|
}
|
|
301
401
|
|
|
302
402
|
return (
|
|
303
403
|
<section data-testid="groups-view-section" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
|
304
|
-
<
|
|
305
|
-
<
|
|
404
|
+
<h2 style={heading}>{labels.groupsView.title}</h2>
|
|
405
|
+
{!(isCreateOpen || editingGroup) && <ActionError fetcher={fetcher} />}
|
|
306
406
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", backgroundColor: "#ffffff", padding: "1rem", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
|
|
307
407
|
<input
|
|
308
408
|
type="text"
|
|
409
|
+
aria-label={labels.groupsView.searchLabel}
|
|
309
410
|
placeholder={labels.groupsView.searchPlaceholder}
|
|
310
411
|
value={searchQuery}
|
|
311
412
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
@@ -335,25 +436,25 @@ export function GroupsView() {
|
|
|
335
436
|
{/* 所属も名称も上流が所有していることを、操作を試す前に伝える */}
|
|
336
437
|
{g.externalLink && (
|
|
337
438
|
<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.
|
|
439
|
+
<span style={{ fontSize: "0.72rem", padding: "2px 8px", borderRadius: "10px", backgroundColor: "#ede9fe", color: "#6d28d9", fontWeight: 600 }}>{labels.common.managedUpstream}</span>
|
|
339
440
|
<span style={{ fontSize: "0.72rem", fontFamily: "monospace", color: "#64748b" }}>{g.externalLink.externalKey}</span>
|
|
340
441
|
{g.externalLink.unresolvedCount > 0 && (
|
|
341
442
|
<span data-testid={`group-unresolved-${g.id}`} style={{ fontSize: "0.72rem", color: "#b45309" }}>
|
|
342
|
-
{g.externalLink.unresolvedCount
|
|
443
|
+
{fill(labels.directoryView.unresolvedTemplate, { count: g.externalLink.unresolvedCount })}
|
|
343
444
|
</span>
|
|
344
445
|
)}
|
|
345
446
|
</div>
|
|
346
447
|
)}
|
|
347
448
|
</div>
|
|
348
449
|
<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
|
-
|
|
450
|
+
<span style={{ fontSize: "0.78rem", color: "#475569" }} data-testid={`group-member-count-${g.id}`}>
|
|
451
|
+
{fill(labels.groupsView.memberCountTemplate, { count: memberCount })}
|
|
351
452
|
</span>
|
|
352
453
|
{/* 上流が所有する行では操作を **描かない**。無効化したボタンを出すと、
|
|
353
454
|
押せる操作と見分けがつかず、押せない理由も伝わらない */}
|
|
354
455
|
{g.readOnly ? (
|
|
355
|
-
<span data-testid={`readonly-group-${g.id}`} style={{ fontSize: "0.75rem", color: "#
|
|
356
|
-
{labels.
|
|
456
|
+
<span data-testid={`readonly-group-${g.id}`} style={{ fontSize: "0.75rem", color: "#475569" }}>
|
|
457
|
+
{labels.common.readOnly}
|
|
357
458
|
</span>
|
|
358
459
|
) : (
|
|
359
460
|
<div>
|
|
@@ -362,14 +463,14 @@ export function GroupsView() {
|
|
|
362
463
|
onClick={() => setEditingGroup(g)}
|
|
363
464
|
data-testid={`edit-group-btn-${g.id}`}
|
|
364
465
|
style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", fontSize: "0.75rem", cursor: "pointer", marginRight: "4px" }}>
|
|
365
|
-
{labels.
|
|
466
|
+
{labels.common.edit}
|
|
366
467
|
</button>
|
|
367
468
|
<button
|
|
368
469
|
type="button"
|
|
369
470
|
onClick={() => handleDeleteGroup(g.id)}
|
|
370
471
|
data-testid={`delete-group-btn-${g.id}`}
|
|
371
472
|
style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #fee2e2", backgroundColor: "#fff5f5", color: "#b91c1c", fontSize: "0.75rem", cursor: "pointer" }}>
|
|
372
|
-
{labels.
|
|
473
|
+
{labels.common.delete}
|
|
373
474
|
</button>
|
|
374
475
|
</div>
|
|
375
476
|
)}
|
|
@@ -378,12 +479,28 @@ export function GroupsView() {
|
|
|
378
479
|
)
|
|
379
480
|
})}
|
|
380
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
|
+
)}
|
|
381
487
|
|
|
382
488
|
{/* Create Group Modal */}
|
|
383
489
|
{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
|
-
|
|
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" />
|
|
387
504
|
<div style={{ marginBottom: "1rem" }}>
|
|
388
505
|
<label htmlFor="create-group-key-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
|
|
389
506
|
{labels.groupsView.groupKey}
|
|
@@ -430,7 +547,7 @@ export function GroupsView() {
|
|
|
430
547
|
<button type="button" onClick={() => setIsCreateOpen(false)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
|
|
431
548
|
キャンセル
|
|
432
549
|
</button>
|
|
433
|
-
<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" }}>
|
|
434
551
|
作成
|
|
435
552
|
</button>
|
|
436
553
|
</div>
|
|
@@ -440,9 +557,20 @@ export function GroupsView() {
|
|
|
440
557
|
|
|
441
558
|
{/* Edit Group Modal */}
|
|
442
559
|
{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
|
-
|
|
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" />
|
|
446
574
|
<div style={{ marginBottom: "1rem" }}>
|
|
447
575
|
<label htmlFor="edit-group-name-input" style={{ display: "block", fontSize: "0.8rem", color: "#475569", marginBottom: "4px" }}>
|
|
448
576
|
{labels.groupsView.name}
|
|
@@ -471,10 +599,10 @@ export function GroupsView() {
|
|
|
471
599
|
</div>
|
|
472
600
|
<div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
|
|
473
601
|
<button type="button" onClick={() => setEditingGroup(null)} style={{ padding: "6px 12px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", cursor: "pointer" }}>
|
|
474
|
-
{labels.
|
|
602
|
+
{labels.common.cancel}
|
|
475
603
|
</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.
|
|
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}
|
|
478
606
|
</button>
|
|
479
607
|
</div>
|
|
480
608
|
</form>
|
|
@@ -491,6 +619,8 @@ export function GroupsView() {
|
|
|
491
619
|
export function GroupMembersView() {
|
|
492
620
|
const data = useLoaderData() as UserAdminLoaderData
|
|
493
621
|
const fetcher = useFetcher()
|
|
622
|
+
const submitThen = useSubmitThen(fetcher)
|
|
623
|
+
const busy = fetcher.state !== "idle"
|
|
494
624
|
const labels = data.labels ?? defaultUserAdminLabels
|
|
495
625
|
|
|
496
626
|
const [selectedGroupId, setSelectedGroupId] = useState<number>(data.groups[0]?.id ?? 0)
|
|
@@ -500,14 +630,15 @@ export function GroupMembersView() {
|
|
|
500
630
|
|
|
501
631
|
const handleToggleMember = (userId: number, isMember: boolean) => {
|
|
502
632
|
if (isMember) {
|
|
503
|
-
|
|
633
|
+
submitThen({ intent: "removeGroupMember", groupId: String(selectedGroupId), userId: String(userId) })
|
|
504
634
|
} else {
|
|
505
|
-
|
|
635
|
+
submitThen({ intent: "addGroupMember", groupId: String(selectedGroupId), userId: String(userId) })
|
|
506
636
|
}
|
|
507
637
|
}
|
|
508
638
|
|
|
509
639
|
return (
|
|
510
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>
|
|
511
642
|
<ActionError fetcher={fetcher} />
|
|
512
643
|
<div style={{ backgroundColor: "#ffffff", padding: "1rem", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
|
|
513
644
|
<h4 style={{ margin: "0 0 0.8rem 0", fontSize: "0.9rem", color: "#475569" }}>{labels.groupMembersView.selectGroup}</h4>
|
|
@@ -532,6 +663,11 @@ export function GroupMembersView() {
|
|
|
532
663
|
fontSize: "0.85rem",
|
|
533
664
|
}}>
|
|
534
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}
|
|
535
671
|
</button>
|
|
536
672
|
)
|
|
537
673
|
})}
|
|
@@ -542,8 +678,15 @@ export function GroupMembersView() {
|
|
|
542
678
|
{currentGroup ? (
|
|
543
679
|
<>
|
|
544
680
|
<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" }}
|
|
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}
|
|
547
690
|
</div>
|
|
548
691
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
|
549
692
|
{data.users.map((u) => {
|
|
@@ -565,29 +708,40 @@ export function GroupMembersView() {
|
|
|
565
708
|
<span style={{ fontWeight: 600, fontSize: "0.88rem", color: "#0f172a", marginRight: "8px" }}>{u.displayName}</span>
|
|
566
709
|
<span style={{ fontSize: "0.78rem", color: "#64748b" }}>({u.email})</span>
|
|
567
710
|
</div>
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
+
)}
|
|
584
738
|
</div>
|
|
585
739
|
)
|
|
586
740
|
})}
|
|
587
741
|
</div>
|
|
588
742
|
</>
|
|
589
743
|
) : (
|
|
590
|
-
<div style={{ color: "#
|
|
744
|
+
<div style={{ color: "#475569", textAlign: "center", padding: "2rem" }}>{labels.groupMembersView.selectPrompt}</div>
|
|
591
745
|
)}
|
|
592
746
|
</div>
|
|
593
747
|
</section>
|
|
@@ -617,6 +771,8 @@ export function AllowlistView() {
|
|
|
617
771
|
const fetcher = useFetcher()
|
|
618
772
|
const labels = data.labels ?? defaultUserAdminLabels
|
|
619
773
|
|
|
774
|
+
const submitThen = useSubmitThen(fetcher)
|
|
775
|
+
const busy = fetcher.state !== "idle"
|
|
620
776
|
const [pattern, setPattern] = useState("")
|
|
621
777
|
const [type, setType] = useState<"email" | "domain">("email")
|
|
622
778
|
const [description, setDescription] = useState("")
|
|
@@ -624,22 +780,24 @@ export function AllowlistView() {
|
|
|
624
780
|
const handleAddEntry = (e: React.FormEvent) => {
|
|
625
781
|
e.preventDefault()
|
|
626
782
|
if (!pattern) return
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
783
|
+
submitThen({ intent: "addAllowlistEntry", pattern, type, description }, () => {
|
|
784
|
+
setPattern("")
|
|
785
|
+
setDescription("")
|
|
786
|
+
})
|
|
630
787
|
}
|
|
631
788
|
|
|
632
789
|
const handleRemoveEntry = (id: number) => {
|
|
633
|
-
|
|
790
|
+
submitThen({ intent: "removeAllowlistEntry", entryId: String(id) })
|
|
634
791
|
}
|
|
635
792
|
|
|
636
793
|
/** グループ許可は別の表に住むため、別の intent で消す。 */
|
|
637
794
|
const handleRemoveGroupEntry = (id: number) => {
|
|
638
|
-
|
|
795
|
+
submitThen({ intent: "removeAllowlistGroup", entryId: String(id) })
|
|
639
796
|
}
|
|
640
797
|
|
|
641
798
|
return (
|
|
642
799
|
<section data-testid="allowlist-view-section" style={{ display: "flex", flexDirection: "column", gap: "1.2rem" }}>
|
|
800
|
+
<h2 style={heading}>{labels.allowlistView.title}</h2>
|
|
643
801
|
<ActionError fetcher={fetcher} />
|
|
644
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" }}>
|
|
645
803
|
<div style={{ flex: 2, minWidth: "220px" }}>
|
|
@@ -683,7 +841,7 @@ export function AllowlistView() {
|
|
|
683
841
|
style={{ width: "100%", padding: "6px 10px", borderRadius: "6px", border: "1px solid #cbd5e1", fontSize: "0.85rem", boxSizing: "border-box" }}
|
|
684
842
|
/>
|
|
685
843
|
</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" }}>
|
|
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" }}>
|
|
687
845
|
{labels.allowlistView.addEntry}
|
|
688
846
|
</button>
|
|
689
847
|
</form>
|
|
@@ -692,22 +850,22 @@ export function AllowlistView() {
|
|
|
692
850
|
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.85rem", textAlign: "left" }}>
|
|
693
851
|
<thead>
|
|
694
852
|
<tr style={{ backgroundColor: "#f8fafc", borderBottom: "1px solid #e2e8f0", color: "#475569" }}>
|
|
695
|
-
<th style={{ padding: "10px 14px" }}>
|
|
853
|
+
<th style={{ padding: "10px 14px" }}>{labels.common.id}</th>
|
|
696
854
|
<th style={{ padding: "10px 14px" }}>{labels.allowlistView.target}</th>
|
|
697
855
|
<th style={{ padding: "10px 14px" }}>{labels.allowlistView.type}</th>
|
|
698
856
|
<th style={{ padding: "10px 14px" }}>{labels.allowlistView.description}</th>
|
|
699
|
-
<th style={{ padding: "10px 14px", textAlign: "right" }}>{labels.
|
|
857
|
+
<th style={{ padding: "10px 14px", textAlign: "right" }}>{labels.common.actions}</th>
|
|
700
858
|
</tr>
|
|
701
859
|
</thead>
|
|
702
860
|
<tbody>
|
|
703
861
|
{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: "#
|
|
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>
|
|
706
864
|
<td style={{ padding: "10px 14px", fontWeight: 600, color: "#0f172a" }}>
|
|
707
865
|
{/* パターン許可とグループ許可では正準値が違う。グループはアドレスではなく
|
|
708
866
|
グループそのものが対象なので、名前とキーを見せる */}
|
|
709
867
|
{item.type === "group" ? (
|
|
710
|
-
<span data-testid={`allowlist-group-${item.id}`}>
|
|
868
|
+
<span data-testid={`allowlist-group-${item.type}-${item.id}`}>
|
|
711
869
|
{item.groupName}
|
|
712
870
|
<span style={{ marginLeft: "6px", fontFamily: "monospace", fontSize: "0.78rem", color: "#64748b" }}>{item.groupKey}</span>
|
|
713
871
|
</span>
|
|
@@ -719,10 +877,10 @@ export function AllowlistView() {
|
|
|
719
877
|
<span style={{ fontSize: "0.75rem", padding: "2px 8px", borderRadius: "10px", ...allowlistBadgeStyle(item.type), fontWeight: 600 }}>{allowlistTypeLabel(item.type, labels)}</span>
|
|
720
878
|
</td>
|
|
721
879
|
<td style={{ padding: "10px 14px", color: "#475569" }}>
|
|
722
|
-
{item.description ||
|
|
880
|
+
{item.description || labels.common.none}
|
|
723
881
|
{/* 配備全体が所有する行は、どのテナントの管理者でも編集できない */}
|
|
724
882
|
{item.owner.kind === "deployment" && (
|
|
725
|
-
<span data-testid={`allowlist-owner-${item.id}`} style={{ marginLeft: "8px", fontSize: "0.72rem", color: "#
|
|
883
|
+
<span data-testid={`allowlist-owner-${item.type}-${item.id}`} style={{ marginLeft: "8px", fontSize: "0.72rem", color: "#475569" }}>
|
|
726
884
|
{labels.allowlistView.deploymentOwner}
|
|
727
885
|
</span>
|
|
728
886
|
)}
|
|
@@ -731,16 +889,24 @@ export function AllowlistView() {
|
|
|
731
889
|
{/* 変更不能な供給元 (環境変数など) は削除ボタンを出さない。無効化ではなく
|
|
732
890
|
非描画にするのは、押せないボタンが押せる操作と見分けられないため */}
|
|
733
891
|
{item.readOnly ? (
|
|
734
|
-
<span data-testid={`readonly-allowlist-${item.id}`} style={{ fontSize: "0.75rem", color: "#
|
|
735
|
-
{labels.
|
|
892
|
+
<span data-testid={`readonly-allowlist-${item.type}-${item.id}`} style={{ fontSize: "0.75rem", color: "#475569" }}>
|
|
893
|
+
{labels.common.readOnly}
|
|
736
894
|
</span>
|
|
737
895
|
) : (
|
|
738
896
|
<button
|
|
739
897
|
type="button"
|
|
740
|
-
onClick={() =>
|
|
741
|
-
|
|
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}`}
|
|
742
908
|
style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #fee2e2", backgroundColor: "#fff5f5", color: "#b91c1c", fontSize: "0.78rem", cursor: "pointer" }}>
|
|
743
|
-
{labels.
|
|
909
|
+
{labels.common.delete}
|
|
744
910
|
</button>
|
|
745
911
|
)}
|
|
746
912
|
</td>
|
|
@@ -748,7 +914,7 @@ export function AllowlistView() {
|
|
|
748
914
|
))}
|
|
749
915
|
{data.allowlist.length === 0 && (
|
|
750
916
|
<tr>
|
|
751
|
-
<td colSpan={5} style={{ padding: "2rem", textAlign: "center", color: "#
|
|
917
|
+
<td colSpan={5} style={{ padding: "2rem", textAlign: "center", color: "#475569" }}>
|
|
752
918
|
{labels.allowlistView.empty}
|
|
753
919
|
</td>
|
|
754
920
|
</tr>
|
|
@@ -760,11 +926,253 @@ export function AllowlistView() {
|
|
|
760
926
|
)
|
|
761
927
|
}
|
|
762
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
|
+
|
|
763
1170
|
const SEGMENT_VIEWS: Record<string, () => React.ReactElement> = {
|
|
764
1171
|
users: UsersView,
|
|
765
1172
|
groups: GroupsView,
|
|
766
1173
|
group_members: GroupMembersView,
|
|
767
1174
|
allowlist: AllowlistView,
|
|
1175
|
+
directory: DirectoryView,
|
|
768
1176
|
}
|
|
769
1177
|
|
|
770
1178
|
export type AuthUserAdminAppViewProps = UserAdminShellProps
|