@aiquants/auth-react-router 0.12.0 → 0.13.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiquants/auth-react-router",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "description": "React Router v7 / v8 auth adapter for @aiquants/auth-core: Google OAuth strategy factory (remix-auth), cookie session storage with DI test backdoor, a loader guard that authenticates at most once per request (token refresh + photo cache), auth route handlers, and a <GoogleForm> login button.",
5
5
  "sideEffects": [
6
6
  "**/*.css"
@@ -21,13 +21,29 @@ export function fill(template: string, values: Record<string, string | number>):
21
21
  }
22
22
 
23
23
  /**
24
- * Formats an ISO timestamp with the locale the labels declare, not the runtime's.
25
- * ISO 時刻を、実行環境ではなく **ラベルが宣言したロケール** で整形する処理。
24
+ * Formats an ISO timestamp with the locale the labels declare, degrading instead of throwing.
25
+ * ISO 時刻を **ラベルが宣言したロケール** で整形する処理 (例外を投げず、読める形へ退避する)。
26
26
  *
27
- * @param iso ISO 8601 timestamp. ISO 8601 の時刻。
27
+ * `Intl.DateTimeFormat().format()` は不正な日時で `RangeError` を投げる (`toLocaleString()` が
28
+ * 「Invalid Date」を返すのとは違う)。この関数を呼ぶのはシェルの同期バナーであり、バナーは
29
+ * **全タブで描かれる**。上流の 1 行が壊れた時刻を持っていただけで、ユーザー管理面が丸ごと
30
+ * エラー境界へ落ちる。ロケール文字列が空でも同じく投げるため、そこも退避する。
31
+ *
32
+ * オフセットの無い ISO 文字列 (`2026-03-01T09:00:00`) は仕様上 **実行環境の時間帯** で解釈される。
33
+ * サーバーとブラウザで別の時刻になり水和が壊れるため、UTC として読む。
34
+ *
35
+ * @param iso ISO 8601 timestamp. ISO 8601 の時刻 (オフセット無しは UTC として解釈)。
28
36
  * @param labels Resolved label set carrying locale and time zone. ロケールと時間帯を持つラベル。
29
- * @returns Formatted text. 整形済みの文言。
37
+ * @returns Formatted text, or the input itself when it cannot be formatted. 整形済みの文言 (整形できない場合は入力そのもの)。
30
38
  */
31
39
  export function formatDateTime(iso: string, labels: UserAdminLabels): string {
32
- return new Intl.DateTimeFormat(labels.locale, { timeZone: labels.timeZone, dateStyle: "medium", timeStyle: "short" }).format(new Date(iso))
40
+ // オフセットが無ければ UTC を補う (実行環境の時間帯で解釈させない)
41
+ const normalized = /(?:Z|[+-]\d{2}:?\d{2})$/.test(iso) ? iso : `${iso}Z`
42
+ try {
43
+ return new Intl.DateTimeFormat(labels.locale, { timeZone: labels.timeZone, dateStyle: "medium", timeStyle: "short" }).format(new Date(normalized))
44
+ } catch {
45
+ // 不正な日時 (`RangeError: Invalid time value`) も、不正なロケール・時間帯も、ここで
46
+ // 受け止める。画面を落とすより生の値を見せる方がまだ役に立つ
47
+ return iso
48
+ }
33
49
  }
@@ -26,7 +26,6 @@ export type UserAdminLabels = {
26
26
  * ここ 1 か所に置く。
27
27
  */
28
28
  common: {
29
- add: string
30
29
  create: string
31
30
  edit: string
32
31
  delete: string
@@ -108,6 +107,9 @@ export type UserAdminLabels = {
108
107
  /** 所属人数。`{count}` を人数へ置換する。 */
109
108
  memberCountTemplate: string
110
109
  confirmDelete: string
110
+ /** 行ごとの読み上げ名。`{group}` をグループ名へ置換する。 */
111
+ editGroupAria: string
112
+ deleteGroupAria: string
111
113
  noDescription: string
112
114
  groupKeyPlaceholder: string
113
115
  namePlaceholder: string
@@ -153,9 +155,6 @@ export type UserAdminLabels = {
153
155
  }
154
156
  directoryView: {
155
157
  title: string
156
- /** 出所列。ローカル管理か、どの上流かを示す。 */
157
- source: string
158
- localSource: string
159
158
  membershipMode: string
160
159
  directMode: string
161
160
  transitiveMode: string
@@ -188,6 +187,7 @@ export type UserAdminLabels = {
188
187
  notWiredNotice: string
189
188
  /** 行ごとの読み上げ名。`{group}` をグループ名へ置換する。 */
190
189
  syncNowAria: string
190
+ syncQueuedAria: string
191
191
  pauseAria: string
192
192
  resumeAria: string
193
193
  allowGroupAria: string
@@ -199,10 +199,6 @@ export type UserAdminLabels = {
199
199
  unlink: string
200
200
  externalKey: string
201
201
  empty: string
202
- /** 直近の同期が失敗したグループの見出し。件数だけでは何が起きたか判らない。 */
203
- erroredWarning: string
204
- /** 同期有効なリンクのうち最も古い成功時刻。滞留の実際の深さを示す。 */
205
- oldestSyncedAt: string
206
202
  /** 連携するグループを選ぶ欄。 */
207
203
  selectGroup: string
208
204
  /** 上流グループのアドレス入力。 */
@@ -230,7 +226,6 @@ export const defaultUserAdminLabels: UserAdminLabels = {
230
226
  timeZone: "UTC",
231
227
  heading: "\u{1F464} User administration",
232
228
  common: {
233
- add: "Add",
234
229
  create: "Create",
235
230
  edit: "Edit",
236
231
  delete: "Delete",
@@ -291,6 +286,8 @@ export const defaultUserAdminLabels: UserAdminLabels = {
291
286
  description: "Description",
292
287
  memberCountTemplate: "{count} members",
293
288
  confirmDelete: "Delete this group?",
289
+ editGroupAria: "Edit {group}",
290
+ deleteGroupAria: "Delete {group}",
294
291
  noDescription: "No description",
295
292
  groupKeyPlaceholder: "e.g. dev-team",
296
293
  namePlaceholder: "e.g. Development team",
@@ -327,8 +324,6 @@ export const defaultUserAdminLabels: UserAdminLabels = {
327
324
  },
328
325
  directoryView: {
329
326
  title: "Directory synchronization",
330
- source: "Source",
331
- localSource: "Local",
332
327
  membershipMode: "Membership",
333
328
  directMode: "Direct only",
334
329
  transitiveMode: "Nested expanded",
@@ -350,6 +345,7 @@ export const defaultUserAdminLabels: UserAdminLabels = {
350
345
  nothingToLink: "Every group is already linked to a directory.",
351
346
  notWiredNotice: "Directory synchronization is not wired for this deployment, so links cannot be created or changed here.",
352
347
  syncNowAria: "Synchronize {group} now",
348
+ syncQueuedAria: "A synchronization of {group} is queued",
353
349
  pauseAria: "Pause synchronization of {group}",
354
350
  resumeAria: "Resume synchronization of {group}",
355
351
  allowGroupAria: "Allow members of {group} to sign in",
@@ -360,8 +356,6 @@ export const defaultUserAdminLabels: UserAdminLabels = {
360
356
  unlink: "Unlink",
361
357
  externalKey: "Upstream group",
362
358
  empty: "No groups are linked to a directory",
363
- erroredWarning: "Last synchronization failed for",
364
- oldestSyncedAt: "Oldest successful sync",
365
359
  selectGroup: "Group to link",
366
360
  externalKeyPlaceholder: "e.g. dev-team@example.com",
367
361
  confirmUnlink: "Unlink this group? Its member ledger and group-based sign-in permission are removed.",
@@ -383,7 +377,6 @@ export const jaUserAdminLabels: UserAdminLabels = {
383
377
  timeZone: "Asia/Tokyo",
384
378
  heading: "\u{1F464} ユーザー管理",
385
379
  common: {
386
- add: "追加",
387
380
  create: "作成",
388
381
  edit: "編集",
389
382
  delete: "削除",
@@ -444,6 +437,8 @@ export const jaUserAdminLabels: UserAdminLabels = {
444
437
  description: "説明",
445
438
  memberCountTemplate: "所属 {count} 名",
446
439
  confirmDelete: "このグループを削除しますか?",
440
+ editGroupAria: "{group} を編集",
441
+ deleteGroupAria: "{group} を削除",
447
442
  noDescription: "説明なし",
448
443
  groupKeyPlaceholder: "例: dev-team",
449
444
  namePlaceholder: "例: 開発チーム",
@@ -480,8 +475,6 @@ export const jaUserAdminLabels: UserAdminLabels = {
480
475
  },
481
476
  directoryView: {
482
477
  title: "ディレクトリ同期",
483
- source: "出所",
484
- localSource: "ローカル管理",
485
478
  membershipMode: "所属の展開",
486
479
  directMode: "直接所属のみ",
487
480
  transitiveMode: "入れ子を展開",
@@ -503,6 +496,7 @@ export const jaUserAdminLabels: UserAdminLabels = {
503
496
  nothingToLink: "すべてのグループが既にディレクトリと連携しています。",
504
497
  notWiredNotice: "この配備ではディレクトリ同期が結線されていないため、ここから連携の作成・変更はできません。",
505
498
  syncNowAria: "{group} を今すぐ同期",
499
+ syncQueuedAria: "{group} の同期を要求済み",
506
500
  pauseAria: "{group} の同期を一時停止",
507
501
  resumeAria: "{group} の同期を再開",
508
502
  allowGroupAria: "{group} のメンバーのサインインを許可",
@@ -513,8 +507,6 @@ export const jaUserAdminLabels: UserAdminLabels = {
513
507
  unlink: "連携を解除",
514
508
  externalKey: "上流グループ",
515
509
  empty: "ディレクトリと連携しているグループはありません",
516
- erroredWarning: "直近の同期に失敗したグループ",
517
- oldestSyncedAt: "最も古い同期成功",
518
510
  selectGroup: "連携するグループ",
519
511
  externalKeyPlaceholder: "例: dev-team@example.com",
520
512
  confirmUnlink: "このグループの連携を解除しますか? メンバー台帳とグループ単位のサインイン許可が失われます。",
@@ -216,6 +216,47 @@ function isForbidden(error: unknown): boolean {
216
216
  return typeof error === "object" && error !== null && (error as { status?: unknown }).status === 403
217
217
  }
218
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
+
236
+ /** 表示するタブ。未知の値は先頭のタブへ寄せる。 */
237
+ const SEGMENTS = ["users", "groups", "group_members", "allowlist", "directory"] as const
238
+
239
+ /**
240
+ * Derives the segment from a request path, tolerating the router's single-fetch suffix.
241
+ * リクエストパスからセグメントを決める処理 (ルーターの単一フェッチ用の接尾辞を許容する)。
242
+ *
243
+ * ⚠️ クライアント側の遷移では、ルーターは画面の URL ではなく **`<path>.data`** を取りに来る
244
+ * (単一フェッチ)。素朴に末尾セグメントを見ると `"groups.data"` になり、どのタブにも一致せず
245
+ * 既定へ落ちる。結果、**URL は変わるのに中身が変わらない** — 全画面再読込でだけ正しく描かれる
246
+ * ため、動作確認では気付きにくい。接尾辞を落としてから判定する。
247
+ *
248
+ * @param pathname Request pathname. リクエストのパス。
249
+ * @returns One of the known segments. 既知のセグメントのいずれか。
250
+ */
251
+ function segmentFromPathname(pathname: string): (typeof SEGMENTS)[number] {
252
+ const parts = pathname
253
+ .replace(/\.data$/, "")
254
+ .split("/")
255
+ .filter(Boolean)
256
+ const last = parts[parts.length - 1] ?? ""
257
+ return (SEGMENTS as readonly string[]).includes(last) ? (last as (typeof SEGMENTS)[number]) : "users"
258
+ }
259
+
219
260
  /**
220
261
  * The CRUD verb an operation performs. Deliberately a plain string union so this package stays
221
262
  * independent of any particular authorization library.
@@ -402,9 +443,7 @@ export function createUserAdminApp(config: UserAdminAppConfig) {
402
443
  async function loader({ request }: RouteArgs) {
403
444
  // 読取ガード: 拒否は throw されるためここで処理が打ち切られる
404
445
  await config.guards.requireAccess(request, { action: "read", intent: "loader" })
405
- const url = new URL(request.url)
406
- const pathSegments = url.pathname.split("/").filter(Boolean)
407
- const segment = pathSegments[pathSegments.length - 1] ?? "users"
446
+ const segment = segmentFromPathname(new URL(request.url).pathname)
408
447
 
409
448
  const [users, groups, groupMembers, allowlist, directoryStatus, myPermissions] = await Promise.all([
410
449
  config.store.listUsers(),
@@ -423,7 +462,7 @@ export function createUserAdminApp(config: UserAdminAppConfig) {
423
462
 
424
463
  return {
425
464
  resourceLabels: config.resourceLabels,
426
- segment: ["groups", "group_members", "allowlist", "directory"].includes(segment) ? segment : "users",
465
+ segment,
427
466
  users,
428
467
  groups,
429
468
  groupMembers,
@@ -445,6 +484,22 @@ export function createUserAdminApp(config: UserAdminAppConfig) {
445
484
  const access = INTENT_ACCESS.get(intent)
446
485
  if (!access) return { ok: false, error: `Unknown intent: ${intent}` }
447
486
 
487
+ /** 403 かどうか。Response でも素の例外でも `status` は共通語彙である。 */
488
+ const isDenied = (error: unknown) => isForbidden(error)
489
+ /**
490
+ * Turns a denial into an in-page error without leaking the guard's own message.
491
+ * 拒否を、ガードの内部文言を漏らさずに画面内エラーへ変える処理。
492
+ */
493
+ const denied = (error: unknown) => {
494
+ try {
495
+ config.guards.onDenied?.({ intent, actions: access, error })
496
+ } catch {
497
+ // 記録に失敗しても拒否の応答は返す。ログの都合で操作の結果が変わってはならない
498
+ }
499
+ // 監視から消えないよう、状態は 403 のまま返す。画面は data() なので生き残る
500
+ return data({ ok: false, error: labels.errors.forbidden }, { status: 403 })
501
+ }
502
+
448
503
  // 操作ごとの CRUD 種別でガードする。複数を要求する intent は **すべて** 通ること
449
504
  try {
450
505
  for (const action of access) await config.guards.requireAccess(request, { action, intent })
@@ -452,17 +507,18 @@ export function createUserAdminApp(config: UserAdminAppConfig) {
452
507
  // 403 は「この操作だけができない」であり、画面ごと落とすと **他の操作まで巻き添え** に
453
508
  // なる (閲覧と作成はできるのに削除だけ拒否された、で一覧が消える)。401 や再認証の
454
509
  // リダイレクト、それ以外の障害はそのまま伝播させる — 握り潰すとログインし直せない
455
- if (!isForbidden(error)) throw error
456
- // 監視から消えないよう、状態は 403 のまま返す。画面は data() なので生き残る
457
- config.guards.onDenied?.({ intent, actions: access, error })
458
- return data({ ok: false, error: labels.errors.forbidden }, { status: 403 })
510
+ if (!isDenied(error)) throw error
511
+ return denied(error)
459
512
  }
460
513
 
461
514
  try {
462
515
  return await dispatch(intent, formData)
463
516
  } catch (error) {
517
+ // ガードの後 (ストアの中) で投げられた拒否も同じ扱いにする。ここを素通りさせると、
518
+ // 拒否理由がそのまま利用者へ渡り、しかも HTTP は 200 になって監視から消える
519
+ if (isDenied(error)) return denied(error)
464
520
  // ガード拒否 (Response/redirect) はそのまま伝播させる。ストア由来の業務エラーだけ
465
- // {ok:false,error} に写して UI へ届ける (ロックアウト理由・重複パターン等)。
521
+ // {ok:false,error} に写して UI へ届ける (ロックアウト理由・重複パターン等)
466
522
  if (error instanceof Response) throw error
467
523
  return { ok: false, error: error instanceof Error ? error.message : String(error) }
468
524
  }
@@ -514,7 +570,7 @@ export function createUserAdminApp(config: UserAdminAppConfig) {
514
570
  }
515
571
 
516
572
  if (intent === "addAllowlistEntry") {
517
- const type: "email" | "domain" = formData.get("type") === "domain" ? "domain" : "email"
573
+ const type = requireAllowlistType(formData)
518
574
  await config.store.addAllowlistEntry({ pattern: requireText(formData, "pattern"), type, description: optionalText(formData, "description") })
519
575
  return { ok: true }
520
576
  }
package/src/admin/ui.tsx CHANGED
@@ -93,6 +93,8 @@ function DirectorySyncBanner({ status, labels, groups }: { status?: AuthDirector
93
93
  * ヘッダー注釈委譲を備えたユーザー管理ページのシェルレイアウト。
94
94
  */
95
95
  export function UserAdminShell({ children, renderHeader }: UserAdminShellProps) {
96
+ // ⚠️ ここは `UserAdminLoaderData` と違い **すべて任意** で受ける。シェルは loader を持たない
97
+ // 呼び出し (エラー境界の外側、テスト、部分描画) からも描かれるため、欠落を落ちる理由にしない
96
98
  const data = useLoaderData() as { segment?: string; labels?: UserAdminLabels; myPermissions?: Record<string, UserAdminPermission | undefined> | null; resourceLabels?: Record<string, string>; directoryStatus?: AuthDirectoryStatus; groups?: AuthGroup[] } | undefined
97
99
  const segment = data?.segment ?? "users"
98
100
  const labels = data?.labels ?? defaultUserAdminLabels
@@ -109,8 +111,13 @@ export function UserAdminShell({ children, renderHeader }: UserAdminShellProps)
109
111
  { key: "allowlist", label: labels.tabs.allowlist, href: "/users/allowlist" },
110
112
  ]
111
113
  // 未結線でも **連携が残っていれば** 出す。停止を警告した直後に、解除や再開ができる唯一の
112
- // 面だけが消えるのは最悪の挙動である。連携も結線も無い配備でだけ隠す
113
- if (data?.directoryStatus && (data.directoryStatus.isEnabled || data.directoryStatus.linkedGroupCount > 0)) tabs.push({ key: "directory", label: labels.tabs.directory, href: "/users/directory" })
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
+ }
114
121
 
115
122
  const permEntries = Object.entries(myPermissions ?? {})
116
123