@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/README.md +100 -7
- package/dist/admin.d.mts +13 -436
- package/dist/admin.d.ts +13 -436
- package/dist/admin.js +1 -2
- package/dist/admin.mjs +1 -2
- package/dist/client.d.mts +1 -10
- package/dist/client.d.ts +1 -10
- package/dist/client.js +0 -1
- package/dist/client.mjs +0 -1
- package/dist/server.d.mts +6 -146
- package/dist/server.d.ts +6 -146
- package/dist/server.js +0 -1
- package/dist/server.mjs +0 -1
- package/package.json +12 -16
- package/dist/admin.js.map +0 -1
- package/dist/admin.mjs.map +0 -1
- package/dist/client.js.map +0 -1
- package/dist/client.mjs.map +0 -1
- package/dist/server.js.map +0 -1
- package/dist/server.mjs.map +0 -1
- package/src/admin/app.ts +0 -9
- package/src/admin/format.ts +0 -58
- package/src/admin/labels.ts +0 -838
- package/src/admin/server.ts +0 -1143
- package/src/admin/ui.tsx +0 -276
- package/src/admin/views.tsx +0 -2120
- package/src/admin.ts +0 -5
- package/src/client/GoogleForm.tsx +0 -47
- package/src/client.ts +0 -6
- package/src/server/auth-server.ts +0 -69
- package/src/server/authenticate-in-loader.ts +0 -243
- package/src/server/authenticate.ts +0 -224
- package/src/server/google-strategy-impl.ts +0 -154
- package/src/server/google-strategy.ts +0 -183
- package/src/server/handlers.ts +0 -139
- package/src/server/logger.ts +0 -14
- package/src/server/ports.ts +0 -113
- package/src/server/session.ts +0 -132
- package/src/server.ts +0 -14
- package/src/styles/standalone.entry.css +0 -6
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
|
-
}
|