@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.
@@ -1,4 +1,5 @@
1
1
  import type { AuthAllowlistEntry, AuthDirectoryMembershipMode, AuthDirectoryStatus, AuthGroup, AuthGroupMember, AuthUser } from "@aiquants/auth-core"
2
+ import { data } from "react-router"
2
3
  import { type PartialUserAdminLabels, resolveUserAdminLabels } from "./labels"
3
4
 
4
5
  /**
@@ -194,11 +195,44 @@ export function createInMemoryUserAdminStore(initial?: { users?: AuthUser[]; gro
194
195
  throw new Error("This in-memory store has no upstream directory; inject a real store to request a sync.")
195
196
  },
196
197
  async getDirectoryStatus() {
197
- return { isEnabled: false, linkedGroupCount: 0, isStale: false, pausedGroupCount: 0, stalenessThresholdSec: 0, erroredGroupIds: [], pendingRequestCount: 0 }
198
+ return { isEnabled: false, provider: "", linkedGroupCount: 0, isStale: false, pausedGroupCount: 0, stalenessThresholdSec: 0, erroredGroupIds: [], pendingRequestCount: 0 }
198
199
  },
199
200
  }
200
201
  }
201
202
 
203
+ /**
204
+ * Tells a per-operation authorization denial apart from any other failure, without importing an authz library.
205
+ * 認可ライブラリを import せずに、操作単位の認可拒否を他の失敗と見分ける判定。
206
+ *
207
+ * 特定の実装の例外クラスへ `instanceof` を張ると、そのライブラリに縛られる (`AGENTS.md` の
208
+ * クロスパッケージ・ハードコード禁止)。`status === 403` は HTTP の共通語彙なので、これだけを見る。
209
+ *
210
+ * @param error The thrown value. 送出された値。
211
+ * @returns True when it carries HTTP 403. HTTP 403 を名乗る場合に true。
212
+ */
213
+ function isForbidden(error: unknown): boolean {
214
+ // Response でも素の例外でも `status` は共通語彙である。特定の実装の例外クラスへ
215
+ // `instanceof` を張ると、そのライブラリに縛られる
216
+ return typeof error === "object" && error !== null && (error as { status?: unknown }).status === 403
217
+ }
218
+
219
+ /**
220
+ * Reads the allowlist kind, refusing anything but the two declared values.
221
+ * 許可の種別を読み取る処理 (宣言された 2 値以外を拒否する)。
222
+ *
223
+ * 指定外を既定値へ倒すと、綴り違い (`Domain`) や欠落が **完全一致の許可** として登録される。
224
+ * 「ドメイン全体を許可したつもりが 1 アドレスだけだった」は、逆向きより静かに壊れる。
225
+ *
226
+ * @param formData Submitted form. 送信されたフォーム。
227
+ * @returns The declared kind. 宣言された種別。
228
+ * @throws {Error} When the value is absent or unknown. 未指定・未知の値の場合。
229
+ */
230
+ function requireAllowlistType(formData: FormData): "email" | "domain" {
231
+ const raw = String(formData.get("type") ?? "")
232
+ if (raw === "email" || raw === "domain") return raw
233
+ throw new Error(`type は "email" または "domain" のいずれかで指定してください (受け取った値: ${JSON.stringify(raw)})`)
234
+ }
235
+
202
236
  /**
203
237
  * The CRUD verb an operation performs. Deliberately a plain string union so this package stays
204
238
  * independent of any particular authorization library.
@@ -230,6 +264,13 @@ export type UserAdminGuards = {
230
264
  * 述語のつもりで `false` を返す実装を書くと素通りするため、返り値型で明示的に禁じる。
231
265
  */
232
266
  requireAccess: (request: Request, context: { action: UserAdminAccess; intent: string }) => Promise<void> | void
267
+ /**
268
+ * Observes a denial that was converted into an in-page error. 画面内エラーへ変換した拒否の観測点。
269
+ *
270
+ * 拒否を画面内に返すと、既定のエラー経路が拾っていたログが消える。管理面への総当たりが
271
+ * 200 番台の列に紛れて見えなくなるため、宿主が記録できる口を開けておく。
272
+ */
273
+ onDenied?: (context: { intent: string; actions: readonly UserAdminAccess[]; error: unknown }) => void
233
274
  }
234
275
 
235
276
  /**
@@ -239,29 +280,31 @@ export type UserAdminGuards = {
239
280
  * 割当の付与・解除は行の追加・削除であるため、認可管理 UI (`/authz`) の
240
281
  * `assignUserRole` = create / `removeUserRole` = delete と同じ粒度に揃える。
241
282
  */
242
- const INTENT_ACCESS = new Map<string, UserAdminAccess>([
243
- ["createUser", "create"],
244
- ["updateUser", "update"],
245
- ["toggleUserActive", "update"],
246
- ["createGroup", "create"],
247
- ["updateGroup", "update"],
248
- ["deleteGroup", "delete"],
249
- ["addGroupMember", "create"],
250
- ["removeGroupMember", "delete"],
251
- ["addAllowlistEntry", "create"],
252
- ["removeAllowlistEntry", "delete"],
253
- ["addAllowlistGroup", "create"],
254
- ["removeAllowlistGroup", "delete"],
255
- // 連携の開始・解除は行の追加削除ではなく、既存グループの属性変更である。create / delete に
256
- // 寄せると、グループ本体を作れない権限でリンクだけ張れてしまう
257
- ["linkDirectoryGroup", "update"],
258
- ["unlinkDirectoryGroup", "update"],
259
- ["setDirectorySyncEnabled", "update"],
283
+ const INTENT_ACCESS = new Map<string, readonly UserAdminAccess[]>([
284
+ ["createUser", ["create"]],
285
+ ["updateUser", ["update"]],
286
+ ["toggleUserActive", ["update"]],
287
+ ["createGroup", ["create"]],
288
+ ["updateGroup", ["update"]],
289
+ ["deleteGroup", ["delete"]],
290
+ ["addGroupMember", ["create"]],
291
+ ["removeGroupMember", ["delete"]],
292
+ ["addAllowlistEntry", ["create"]],
293
+ ["removeAllowlistEntry", ["delete"]],
294
+ ["addAllowlistGroup", ["create"]],
295
+ ["removeAllowlistGroup", ["delete"]],
296
+ // 連携の開始は、以後の所属の **追加と削除の両方** を上流へ委ねる行為である。所属を足せる
297
+ // だけの主体が「足すことも消すこともできる仕掛け」を据えられてはならない。属性変更でもある
298
+ // ため update も要る
299
+ ["linkDirectoryGroup", ["update", "create", "delete"]],
300
+ // 解除は台帳とグループ単位のサインイン許可を **消す**。属性を戻すだけではないので、
301
+ // 削除権限を持たない主体には許さない
302
+ ["unlinkDirectoryGroup", ["update", "delete"]],
303
+ ["setDirectorySyncEnabled", ["update"]],
260
304
  // 書き込みを伴わないが update を要求する。読取権限だけの主体が上流問い合わせを誘発できると、
261
305
  // レート制限を外部から枯渇させられる
262
- ["requestDirectorySync", "update"],
306
+ ["requestDirectorySync", ["update"]],
263
307
  ])
264
-
265
308
  /**
266
309
  * Neutral per-resource permission summary the admin shell renders in its header badge.
267
310
  * 管理シェルのヘッダーバッジが描画する、リソース単位の中立な権限サマリ。
@@ -419,14 +462,41 @@ export function createUserAdminApp(config: UserAdminAppConfig) {
419
462
  const access = INTENT_ACCESS.get(intent)
420
463
  if (!access) return { ok: false, error: `Unknown intent: ${intent}` }
421
464
 
422
- // 操作ごとの CRUD 種別でガードする (削除は delete 権限が要る)
423
- await config.guards.requireAccess(request, { action: access, intent })
465
+ /** 403 かどうか。Response でも素の例外でも `status` は共通語彙である。 */
466
+ const isDenied = (error: unknown) => isForbidden(error)
467
+ /**
468
+ * Turns a denial into an in-page error without leaking the guard's own message.
469
+ * 拒否を、ガードの内部文言を漏らさずに画面内エラーへ変える処理。
470
+ */
471
+ const denied = (error: unknown) => {
472
+ try {
473
+ config.guards.onDenied?.({ intent, actions: access, error })
474
+ } catch {
475
+ // 記録に失敗しても拒否の応答は返す。ログの都合で操作の結果が変わってはならない
476
+ }
477
+ // 監視から消えないよう、状態は 403 のまま返す。画面は data() なので生き残る
478
+ return data({ ok: false, error: labels.errors.forbidden }, { status: 403 })
479
+ }
480
+
481
+ // 操作ごとの CRUD 種別でガードする。複数を要求する intent は **すべて** 通ること
482
+ try {
483
+ for (const action of access) await config.guards.requireAccess(request, { action, intent })
484
+ } catch (error) {
485
+ // 403 は「この操作だけができない」であり、画面ごと落とすと **他の操作まで巻き添え** に
486
+ // なる (閲覧と作成はできるのに削除だけ拒否された、で一覧が消える)。401 や再認証の
487
+ // リダイレクト、それ以外の障害はそのまま伝播させる — 握り潰すとログインし直せない
488
+ if (!isDenied(error)) throw error
489
+ return denied(error)
490
+ }
424
491
 
425
492
  try {
426
493
  return await dispatch(intent, formData)
427
494
  } catch (error) {
495
+ // ガードの後 (ストアの中) で投げられた拒否も同じ扱いにする。ここを素通りさせると、
496
+ // 拒否理由がそのまま利用者へ渡り、しかも HTTP は 200 になって監視から消える
497
+ if (isDenied(error)) return denied(error)
428
498
  // ガード拒否 (Response/redirect) はそのまま伝播させる。ストア由来の業務エラーだけ
429
- // {ok:false,error} に写して UI へ届ける (ロックアウト理由・重複パターン等)。
499
+ // {ok:false,error} に写して UI へ届ける (ロックアウト理由・重複パターン等)
430
500
  if (error instanceof Response) throw error
431
501
  return { ok: false, error: error instanceof Error ? error.message : String(error) }
432
502
  }
@@ -478,7 +548,7 @@ export function createUserAdminApp(config: UserAdminAppConfig) {
478
548
  }
479
549
 
480
550
  if (intent === "addAllowlistEntry") {
481
- const type: "email" | "domain" = formData.get("type") === "domain" ? "domain" : "email"
551
+ const type = requireAllowlistType(formData)
482
552
  await config.store.addAllowlistEntry({ pattern: requireText(formData, "pattern"), type, description: optionalText(formData, "description") })
483
553
  return { ok: true }
484
554
  }
package/src/admin/ui.tsx CHANGED
@@ -1,5 +1,7 @@
1
+ import type { AuthDirectoryStatus, AuthGroup } from "@aiquants/auth-core"
1
2
  import type React from "react"
2
3
  import { Link, useLoaderData } from "react-router"
4
+ import { fill, formatDateTime } from "./format"
3
5
  import { defaultUserAdminLabels, type UserAdminLabels } from "./labels"
4
6
  import type { UserAdminPermission } from "./server"
5
7
 
@@ -34,12 +36,66 @@ function GroupPill({ label, summary }: { label: string; summary: { text: string;
34
36
  )
35
37
  }
36
38
 
39
+ /**
40
+ * Warns when synchronization is not actually keeping the mirror fresh.
41
+ * 同期が実際には鏡を新しく保てていないことを警告する部品。
42
+ *
43
+ * 同期の停止は「上流から外れた人がテナントに居続ける」というセキュリティ事象である。件数と
44
+ * エラー数だけを見ると何も起きていないように読めるため、**滞留・全停止・未結線の 3 つを
45
+ * それぞれ名指しで** 出す。出さなければ、状態を計算している意味がない。
46
+ *
47
+ * どのタブに居ても見えるようにシェル側で描く。ディレクトリの停止はグループ一覧を見ている
48
+ * ときだけ起きる事象ではない。
49
+ */
50
+ function DirectorySyncBanner({ status, labels, groups }: { status?: AuthDirectoryStatus; labels: UserAdminLabels; groups: AuthGroup[] }) {
51
+ if (!status || status.linkedGroupCount === 0) return null
52
+ const L = labels.directoryView
53
+ const warnings: Array<{ id: string; text: string }> = []
54
+ if (!status.isEnabled) warnings.push({ id: "disabled", text: L.disabledWarning })
55
+ if (status.pausedGroupCount > 0) warnings.push({ id: "paused", text: fill(L.pausedTemplate, { paused: status.pausedGroupCount, total: status.linkedGroupCount }) })
56
+ if (status.isStale) {
57
+ const at = status.oldestSyncedAt ? formatDateTime(status.oldestSyncedAt, labels) : L.neverSynced
58
+ warnings.push({ id: "stale", text: `${L.staleWarning} ${fill(L.oldestSyncedTemplate, { at })}` })
59
+ }
60
+ if (status.erroredGroupIds.length > 0) {
61
+ // ID を並べても運用者には何のグループか判らない。名前と、あれば実際の失敗理由を出す
62
+ const described = status.erroredGroupIds.map((id) => {
63
+ const group = groups.find((g) => g.id === id)
64
+ if (!group) return fill(labels.common.idTemplate, { id })
65
+ const reason = group.externalLink?.syncError
66
+ return reason ? fill(L.erroredItemTemplate, { group: group.name, reason }) : group.name
67
+ })
68
+ warnings.push({ id: "errored", text: fill(L.erroredTemplate, { groups: described.join(L.listSeparator) }) })
69
+ }
70
+ if (status.pendingRequestCount > 0) warnings.push({ id: "pending", text: fill(L.pendingTemplate, { count: status.pendingRequestCount }) })
71
+ if (warnings.length === 0) return null
72
+ return (
73
+ // ライブリージョンにはしない。初期描画から在る内容は読み上げられず、見出しも読み順も
74
+ // 失われる。**読める本文** として置き、見出しから辿れるようにする
75
+ <section
76
+ aria-labelledby="directory-sync-heading"
77
+ data-testid="directory-sync-banner"
78
+ 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" }}>
79
+ <h2 id="directory-sync-heading" style={{ margin: 0, fontSize: "0.9rem", fontWeight: 700 }}>
80
+ {L.title}
81
+ </h2>
82
+ {warnings.map((w) => (
83
+ <span key={w.id} data-directory-warning={w.id}>
84
+ {w.text}
85
+ </span>
86
+ ))}
87
+ </section>
88
+ )
89
+ }
90
+
37
91
  /**
38
92
  * Shell layout for user administration pages with header annotation delegation.
39
93
  * ヘッダー注釈委譲を備えたユーザー管理ページのシェルレイアウト。
40
94
  */
41
95
  export function UserAdminShell({ children, renderHeader }: UserAdminShellProps) {
42
- const data = useLoaderData() as { segment?: string; labels?: UserAdminLabels; myPermissions?: Record<string, UserAdminPermission | undefined> | null; resourceLabels?: Record<string, string> } | undefined
96
+ // ⚠️ ここは `UserAdminLoaderData` と違い **すべて任意** で受ける。シェルは loader を持たない
97
+ // 呼び出し (エラー境界の外側、テスト、部分描画) からも描かれるため、欠落を落ちる理由にしない
98
+ const data = useLoaderData() as { segment?: string; labels?: UserAdminLabels; myPermissions?: Record<string, UserAdminPermission | undefined> | null; resourceLabels?: Record<string, string>; directoryStatus?: AuthDirectoryStatus; groups?: AuthGroup[] } | undefined
43
99
  const segment = data?.segment ?? "users"
44
100
  const labels = data?.labels ?? defaultUserAdminLabels
45
101
  // null = 未結線または取得失敗。空 {} も「分からない」扱いで、いずれも許可として描画しない
@@ -54,6 +110,14 @@ export function UserAdminShell({ children, renderHeader }: UserAdminShellProps)
54
110
  { key: "group_members", label: labels.tabs.groupMembers, href: "/users/group_members" },
55
111
  { key: "allowlist", label: labels.tabs.allowlist, href: "/users/allowlist" },
56
112
  ]
113
+ // 未結線でも **連携が残っていれば** 出す。停止を警告した直後に、解除や再開ができる唯一の
114
+ // 面だけが消えるのは最悪の挙動である。連携も結線も無い配備でだけ隠す。
115
+ // 判定は状態の件数と一覧の実体の **両方** を見る — 片方だけだと、件数が 0 なのに
116
+ // 「上流が所有」と描かれるグループが取り残される
117
+ const hasLinkedGroup = (data?.groups ?? []).some((g) => g.externalLink)
118
+ if (data?.directoryStatus?.isEnabled || (data?.directoryStatus?.linkedGroupCount ?? 0) > 0 || hasLinkedGroup) {
119
+ tabs.push({ key: "directory", label: labels.tabs.directory, href: "/users/directory" })
120
+ }
57
121
 
58
122
  const permEntries = Object.entries(myPermissions ?? {})
59
123
 
@@ -66,21 +130,21 @@ export function UserAdminShell({ children, renderHeader }: UserAdminShellProps)
66
130
  */
67
131
  const getSummary = (perm: UserAdminPermission | undefined) => {
68
132
  if (perm?.canWrite) {
69
- return { text: "編集可", color: "#047857", bgColor: "#ecfdf5", borderColor: "#a7f3d0" }
133
+ return { text: labels.permissions.canWrite, color: "#047857", bgColor: "#ecfdf5", borderColor: "#a7f3d0" }
70
134
  }
71
135
  if (perm?.canRead) {
72
- return { text: "閲覧のみ", color: "#1d4ed8", bgColor: "#eff6ff", borderColor: "#bfdbfe" }
136
+ return { text: labels.permissions.canRead, color: "#1d4ed8", bgColor: "#eff6ff", borderColor: "#bfdbfe" }
73
137
  }
74
- return { text: "権限なし", color: "#64748b", bgColor: "#f1f5f9", borderColor: "#cbd5e1" }
138
+ return { text: labels.permissions.none, color: "#475569", bgColor: "#f1f5f9", borderColor: "#cbd5e1" }
75
139
  }
76
- const unavailableSummary = { text: "権限不明", color: "#92400e", bgColor: "#fffbeb", borderColor: "#fde68a" }
140
+ const unavailableSummary = { text: labels.permissions.unknown, color: "#92400e", bgColor: "#fffbeb", borderColor: "#fde68a" }
77
141
 
78
142
  const annotation = (
79
143
  <div style={{ display: "flex", alignItems: "center", gap: "0.4rem" }} data-testid="user-admin-header-annotation">
80
144
  <span style={{ fontSize: "0.75rem", backgroundColor: "#e0f2fe", color: "#0369a1", padding: "2px 8px", borderRadius: "12px", fontWeight: 600 }}>{labels.heading}</span>
81
145
 
82
146
  <div style={{ display: "inline-flex", alignItems: "center", gap: "0.25rem", fontSize: "0.68rem", color: "#475569" }} data-testid="user-admin-permissions-badge">
83
- <span style={{ fontWeight: 600, color: "#334155" }}>あなたの権限:</span>
147
+ <span style={{ fontWeight: 600, color: "#334155" }}>{labels.permissions.label}</span>
84
148
  <span style={{ display: "inline-flex", alignItems: "center", gap: "0.18rem" }}>
85
149
  {permEntries.length === 0 ? (
86
150
  <span data-testid="user-admin-permission-unavailable">
@@ -105,7 +169,7 @@ export function UserAdminShell({ children, renderHeader }: UserAdminShellProps)
105
169
  </div>
106
170
  )}
107
171
 
108
- <div style={{ display: "flex", gap: "0.5rem", marginBottom: "1.2rem", borderBottom: "2px solid #e2e8f0", paddingBottom: "0.5rem" }}>
172
+ <nav aria-label={labels.title} style={{ display: "flex", gap: "0.5rem", marginBottom: "1.2rem", borderBottom: "2px solid #e2e8f0", paddingBottom: "0.5rem" }}>
109
173
  {tabs.map((tab) => {
110
174
  const isActive = segment === tab.key
111
175
  return (
@@ -113,6 +177,7 @@ export function UserAdminShell({ children, renderHeader }: UserAdminShellProps)
113
177
  key={tab.key}
114
178
  to={tab.href}
115
179
  data-testid={`user-admin-tab-${tab.key}`}
180
+ aria-current={isActive ? "page" : undefined}
116
181
  style={{
117
182
  padding: "0.5rem 1rem",
118
183
  borderRadius: "6px 6px 0 0",
@@ -128,7 +193,9 @@ export function UserAdminShell({ children, renderHeader }: UserAdminShellProps)
128
193
  </Link>
129
194
  )
130
195
  })}
131
- </div>
196
+ </nav>
197
+
198
+ <DirectorySyncBanner status={data?.directoryStatus} labels={labels} groups={data?.groups ?? []} />
132
199
 
133
200
  <main>{children}</main>
134
201
  </div>