@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/dist/admin.d.mts +5 -9
- package/dist/admin.d.ts +5 -9
- 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 +1 -1
- package/src/admin/format.ts +21 -5
- package/src/admin/labels.ts +10 -18
- package/src/admin/server.ts +66 -10
- package/src/admin/ui.tsx +9 -2
- package/src/admin/views.tsx +127 -65
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiquants/auth-react-router",
|
|
3
|
-
"version": "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"
|
package/src/admin/format.ts
CHANGED
|
@@ -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,
|
|
25
|
-
* ISO
|
|
24
|
+
* Formats an ISO timestamp with the locale the labels declare, degrading instead of throwing.
|
|
25
|
+
* ISO 時刻を **ラベルが宣言したロケール** で整形する処理 (例外を投げず、読める形へ退避する)。
|
|
26
26
|
*
|
|
27
|
-
*
|
|
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
|
-
|
|
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
|
}
|
package/src/admin/labels.ts
CHANGED
|
@@ -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: "このグループの連携を解除しますか? メンバー台帳とグループ単位のサインイン許可が失われます。",
|
package/src/admin/server.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 (!
|
|
456
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|