@aiquants/auth-react-router 0.15.0 → 0.16.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/src/admin/ui.tsx DELETED
@@ -1,276 +0,0 @@
1
- /**
2
- * @module admin/ui
3
- * @description The shell around the five tab views: heading, tabs, badges and banners.
4
- * 5 つのタブビューを囲む外枠 (見出し・タブ・バッジ・告知)。
5
- */
6
- import type { AuthDirectoryStatus, AuthGroup } from "@aiquants/auth-core"
7
- import type React from "react"
8
- import { useEffect, useRef } from "react"
9
- import { Link, useLoaderData, useRevalidator } from "react-router"
10
- import { fill, formatDateTime } from "./format"
11
- import { defaultUserAdminLabels, type UserAdminLabels } from "./labels"
12
- import type { UserAdminPermission } from "./server"
13
-
14
- /**
15
- * How the console keeps itself current while "sync now" requests are waiting for the job.
16
- * 「今すぐ同期」の要求がジョブを待っている間、画面が自分を最新に保つ間隔と上限。
17
- *
18
- * The loader only re-runs after the console's own submissions, so a request the job finishes a few
19
- * seconds later would stay invisible until a reload. While at least one request is pending the
20
- * console re-validates every `intervalMs`, and gives up after `maxMs` so a deployment with no job
21
- * consuming the queue does not poll forever from every open tab.
22
- * ローダーは自分の送信のあとにしか再実行されないため、ジョブが数秒後に終えた要求はリロードまで
23
- * 見えない。未処理の要求が 1 件でもある間は `intervalMs` ごとに再検証し、`maxMs` で諦める
24
- * (キューを消化するジョブが居ない配備で、開いている全タブが永久に叩き続けないため)。
25
- */
26
- export type PendingSyncPolling = {
27
- /** Milliseconds between re-validations. 再検証の間隔 (ミリ秒)。 */
28
- intervalMs: number
29
- /** Milliseconds after which polling stops for an unchanged pending count. 件数が変わらないまま諦めるまでの時間 (ミリ秒)。 */
30
- maxMs: number
31
- }
32
-
33
- /** Defaults: every 3 seconds, for up to 5 minutes. 既定値 (3 秒ごと、最長 5 分)。 */
34
- export const DEFAULT_PENDING_SYNC_POLLING: PendingSyncPolling = { intervalMs: 3_000, maxMs: 300_000 }
35
-
36
- export type UserAdminShellProps = {
37
- children?: React.ReactNode
38
- renderHeader?: (props: { title: string; annotation: React.ReactNode }) => React.ReactNode
39
- /** Polling while sync requests are pending; defaults to {@link DEFAULT_PENDING_SYNC_POLLING}. 未処理中のポーリング設定。 */
40
- pendingSyncPolling?: PendingSyncPolling
41
- }
42
-
43
- /**
44
- * Re-validates the route data periodically while sync requests are waiting for the job.
45
- * 同期要求がジョブを待っている間、ルートのデータを定期的に再検証する処理。
46
- *
47
- * @param pendingRequestCount Requests not yet finished, from the loader. ローダーが返した未処理件数。
48
- * @param polling Interval and cap. 間隔と上限。
49
- * @returns Nothing. 戻り値なし。
50
- */
51
- function usePendingSyncRevalidation(pendingRequestCount: number, polling: PendingSyncPolling): void {
52
- const revalidator = useRevalidator()
53
- // 進行中の再検証に重ねない。重ねると応答順が入れ替わり、古い結果が新しい結果を上書きし得る。
54
- // 効果のクロージャは古い state を掴むので、毎描画で更新する参照から読む
55
- const stateRef = useRef(revalidator.state)
56
- stateRef.current = revalidator.state
57
- const { revalidate } = revalidator
58
- const { intervalMs, maxMs } = polling
59
-
60
- useEffect(() => {
61
- if (pendingRequestCount <= 0) return
62
- // 件数が変わるたびに効果が張り直され、上限も測り直される。上限が効くのは件数が
63
- // 変わらないまま (誰にも拾われないまま) 時間だけが過ぎる場合である
64
- const startedAt = Date.now()
65
- const timer = setInterval(() => {
66
- if (Date.now() - startedAt >= maxMs) {
67
- clearInterval(timer)
68
- return
69
- }
70
- if (stateRef.current === "idle") void revalidate()
71
- }, intervalMs)
72
- return () => clearInterval(timer)
73
- }, [pendingRequestCount, intervalMs, maxMs, revalidate])
74
- }
75
-
76
- /**
77
- * Render one resource's permission summary as a compact pill.
78
- * 1 リソース分の権限サマリをコンパクトなタグとして描画するコンポーネント。
79
- */
80
- function GroupPill({ label, summary }: { label: string; summary: { text: string; color: string; bgColor: string; borderColor: string } }) {
81
- return (
82
- <span
83
- style={{
84
- display: "inline-flex",
85
- alignItems: "center",
86
- gap: "0.18rem",
87
- fontSize: "0.65rem",
88
- padding: "0.08rem 0.35rem",
89
- borderRadius: "9999px",
90
- backgroundColor: summary.bgColor,
91
- color: summary.color,
92
- border: `1px solid ${summary.borderColor}`,
93
- fontWeight: 600,
94
- lineHeight: "1.2",
95
- }}>
96
- <span>{label}:</span>
97
- <span>{summary.text}</span>
98
- </span>
99
- )
100
- }
101
-
102
- /**
103
- * Warns when synchronization is not actually keeping the mirror fresh.
104
- * 同期が実際には鏡を新しく保てていないことを警告する部品。
105
- *
106
- * 同期の停止は「上流から外れた人がテナントに居続ける」というセキュリティ事象である。件数と
107
- * エラー数だけを見ると何も起きていないように読めるため、**滞留・全停止・未結線の 3 つを
108
- * それぞれ名指しで** 出す。出さなければ、状態を計算している意味がない。
109
- *
110
- * どのタブに居ても見えるようにシェル側で描く。ディレクトリの停止はグループ一覧を見ている
111
- * ときだけ起きる事象ではない。
112
- */
113
- function DirectorySyncBanner({ status, labels, groups }: { status?: AuthDirectoryStatus; labels: UserAdminLabels; groups: AuthGroup[] }) {
114
- if (!status || status.linkedGroupCount === 0) return null
115
- const L = labels.directoryView
116
- const warnings: Array<{ id: string; text: string }> = []
117
- if (!status.isEnabled) warnings.push({ id: "disabled", text: L.disabledWarning })
118
- if (status.pausedGroupCount > 0) warnings.push({ id: "paused", text: fill(L.pausedTemplate, { paused: status.pausedGroupCount, total: status.linkedGroupCount }) })
119
- if (status.isStale) {
120
- const at = status.oldestSyncedAt ? formatDateTime(status.oldestSyncedAt, labels) : L.neverSynced
121
- warnings.push({ id: "stale", text: `${L.staleWarning} ${fill(L.oldestSyncedTemplate, { at })}` })
122
- }
123
- if (status.erroredGroupIds.length > 0) {
124
- // ID を並べても運用者には何のグループか判らない。名前と、あれば実際の失敗理由を出す
125
- const described = status.erroredGroupIds.map((id) => {
126
- const group = groups.find((g) => g.id === id)
127
- if (!group) return fill(labels.common.idTemplate, { id })
128
- const reason = group.externalLink?.syncError
129
- return reason ? fill(L.erroredItemTemplate, { group: group.name, reason }) : group.name
130
- })
131
- warnings.push({ id: "errored", text: fill(L.erroredTemplate, { groups: described.join(L.listSeparator) }) })
132
- }
133
- if (status.pendingRequestCount > 0) warnings.push({ id: "pending", text: fill(L.pendingTemplate, { count: status.pendingRequestCount }) })
134
- if (warnings.length === 0) return null
135
- return (
136
- // ライブリージョンにはしない。初期描画から在る内容は読み上げられず、見出しも読み順も
137
- // 失われる。**読める本文** として置き、見出しから辿れるようにする
138
- <section
139
- aria-labelledby="directory-sync-heading"
140
- data-testid="directory-sync-banner"
141
- style={{ display: "flex", flexDirection: "column", gap: "0.35rem", padding: "0.7rem 1rem", marginBottom: "1rem", borderRadius: "6px", border: "1px solid #fde68a", backgroundColor: "#fffbeb", color: "#92400e", fontSize: "0.85rem" }}>
142
- <h2 id="directory-sync-heading" style={{ margin: 0, fontSize: "0.9rem", fontWeight: 700 }}>
143
- {L.title}
144
- </h2>
145
- {warnings.map((w) => (
146
- <span key={w.id} data-directory-warning={w.id}>
147
- {w.text}
148
- </span>
149
- ))}
150
- </section>
151
- )
152
- }
153
-
154
- /**
155
- * Shell layout for user administration pages with header annotation delegation.
156
- * ヘッダー注釈委譲を備えたユーザー管理ページのシェルレイアウト。
157
- */
158
- export function UserAdminShell({ children, renderHeader, pendingSyncPolling }: UserAdminShellProps) {
159
- // ⚠️ ここは `UserAdminLoaderData` と違い **すべて任意** で受ける。シェルは loader を持たない
160
- // 呼び出し (エラー境界の外側、テスト、部分描画) からも描かれるため、欠落を落ちる理由にしない
161
- const data = useLoaderData() as { segment?: string; labels?: UserAdminLabels; myPermissions?: Record<string, UserAdminPermission | undefined> | null; resourceLabels?: Record<string, string>; directoryStatus?: AuthDirectoryStatus; groups?: AuthGroup[] } | undefined
162
- // 同期状態が無い呼び出し (ディレクトリ未結線・部分描画) に未処理の要求は存在しない
163
- usePendingSyncRevalidation(data?.directoryStatus?.pendingRequestCount ?? 0, pendingSyncPolling ?? DEFAULT_PENDING_SYNC_POLLING)
164
- const segment = data?.segment ?? "users"
165
- const labels = data?.labels ?? defaultUserAdminLabels
166
- // null = 未結線または取得失敗。空 {} も「分からない」扱いで、いずれも許可として描画しない
167
- const myPermissions = data?.myPermissions ?? null
168
- // リソースキーの表示名は呼び出し側が差し込む (キーをこのパッケージに埋め込まない)
169
- const resourceLabels = data?.resourceLabels ?? {}
170
- const labelFor = (resourceKey: string) => (Object.hasOwn(resourceLabels, resourceKey) ? resourceLabels[resourceKey] : resourceKey)
171
-
172
- const tabs = [
173
- { key: "users", label: labels.tabs.users, href: "/users" },
174
- { key: "groups", label: labels.tabs.groups, href: "/users/groups" },
175
- { key: "group_members", label: labels.tabs.groupMembers, href: "/users/group_members" },
176
- { key: "allowlist", label: labels.tabs.allowlist, href: "/users/allowlist" },
177
- ]
178
- // 未結線でも **連携が残っていれば** 出す。停止を警告した直後に、解除や再開ができる唯一の
179
- // 面だけが消えるのは最悪の挙動である。連携も結線も無い配備でだけ隠す。
180
- // 判定は状態の件数と一覧の実体の **両方** を見る — 片方だけだと、件数が 0 なのに
181
- // 「上流が所有」と描かれるグループが取り残される
182
- const hasLinkedGroup = (data?.groups ?? []).some((g) => g.externalLink)
183
- if (data?.directoryStatus?.isEnabled || (data?.directoryStatus?.linkedGroupCount ?? 0) > 0 || hasLinkedGroup) {
184
- tabs.push({ key: "directory", label: labels.tabs.directory, href: "/users/directory" })
185
- }
186
-
187
- const permEntries = Object.entries(myPermissions ?? {})
188
-
189
- /**
190
- * Pick the badge text and colours for one resource, never rendering "unknown" as "allowed".
191
- * 1 リソース分のバッジ文言と配色を決める処理。**未知を許可として描かない** ことが要件。
192
- *
193
- * 以前は権限マップが空のとき「編集可」の固定値を描いていたため、ポート未結線・取得失敗・
194
- * 付与ゼロのいずれでも「全操作できます」と表示されていた (実際には操作するとガードで拒否される)。
195
- */
196
- const unavailableSummary = { text: labels.permissions.unknown, color: "#92400e", bgColor: "#fffbeb", borderColor: "#fde68a" }
197
- const getSummary = (perm: UserAdminPermission | undefined) => {
198
- // ⚠️ **鍵は在るが値が無い** のは「権限なし」ではなく「判らない」である。取得側の型が
199
- // `undefined` を許す以上、その差を潰すと、拒否理由が「バッジを見てください」と案内する
200
- // 相手に嘘のバッジを見せることになる
201
- if (perm === undefined) return unavailableSummary
202
- if (perm.canWrite) {
203
- return { text: labels.permissions.canWrite, color: "#047857", bgColor: "#ecfdf5", borderColor: "#a7f3d0" }
204
- }
205
- if (perm.canRead) {
206
- return { text: labels.permissions.canRead, color: "#1d4ed8", bgColor: "#eff6ff", borderColor: "#bfdbfe" }
207
- }
208
- // ⚠️ 削除だけを持つ主体は実在する。読み書きが無いことを「権限なし」と描くと、
209
- // 実際には削除できる相手に「何もできない」と言うことになる
210
- if (perm.canDelete) {
211
- return { text: labels.permissions.canDelete, color: "#b91c1c", bgColor: "#fef2f2", borderColor: "#fecaca" }
212
- }
213
- return { text: labels.permissions.none, color: "#475569", bgColor: "#f1f5f9", borderColor: "#cbd5e1" }
214
- }
215
- const annotation = (
216
- <div style={{ display: "flex", alignItems: "center", gap: "0.4rem" }} data-testid="user-admin-header-annotation">
217
- <span style={{ fontSize: "0.75rem", backgroundColor: "#e0f2fe", color: "#0369a1", padding: "2px 8px", borderRadius: "12px", fontWeight: 600 }}>{labels.heading}</span>
218
-
219
- <div style={{ display: "inline-flex", alignItems: "center", gap: "0.25rem", fontSize: "0.68rem", color: "#475569" }} data-testid="user-admin-permissions-badge">
220
- <span style={{ fontWeight: 600, color: "#334155" }}>{labels.permissions.label}</span>
221
- <span style={{ display: "inline-flex", alignItems: "center", gap: "0.18rem" }}>
222
- {permEntries.length === 0 ? (
223
- <span data-testid="user-admin-permission-unavailable">
224
- <GroupPill label={labels.heading} summary={unavailableSummary} />
225
- </span>
226
- ) : (
227
- permEntries.map(([resKey, perm]) => <GroupPill key={resKey} label={labelFor(resKey)} summary={getSummary(perm)} />)
228
- )}
229
- </span>
230
- </div>
231
- </div>
232
- )
233
-
234
- return (
235
- <div data-testid="user-admin-shell" style={{ width: "100%", minHeight: "100vh", backgroundColor: "#f8fafc", padding: "1.5rem", boxSizing: "border-box" }}>
236
- {renderHeader ? (
237
- renderHeader({ title: labels.title, annotation })
238
- ) : (
239
- <div style={{ marginBottom: "1.5rem", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
240
- <h1 style={{ margin: 0, fontSize: "1.5rem", fontWeight: 700, color: "#0f172a" }}>{labels.title}</h1>
241
- {annotation}
242
- </div>
243
- )}
244
-
245
- <nav aria-label={labels.title} style={{ display: "flex", gap: "0.5rem", marginBottom: "1.2rem", borderBottom: "2px solid #e2e8f0", paddingBottom: "0.5rem" }}>
246
- {tabs.map((tab) => {
247
- const isActive = segment === tab.key
248
- return (
249
- <Link
250
- key={tab.key}
251
- to={tab.href}
252
- data-testid={`user-admin-tab-${tab.key}`}
253
- aria-current={isActive ? "page" : undefined}
254
- style={{
255
- padding: "0.5rem 1rem",
256
- borderRadius: "6px 6px 0 0",
257
- fontSize: "0.88rem",
258
- fontWeight: isActive ? 700 : 500,
259
- color: isActive ? "#0284c7" : "#64748b",
260
- backgroundColor: isActive ? "#ffffff" : "transparent",
261
- textDecoration: "none",
262
- borderBottom: isActive ? "3px solid #0284c7" : "3px solid transparent",
263
- transition: "all 0.15s ease",
264
- }}>
265
- {tab.label}
266
- </Link>
267
- )
268
- })}
269
- </nav>
270
-
271
- <DirectorySyncBanner status={data?.directoryStatus} labels={labels} groups={data?.groups ?? []} />
272
-
273
- <main>{children}</main>
274
- </div>
275
- )
276
- }