@aiquants/auth-react-router 0.5.0 → 0.7.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,3 +1,3 @@
1
1
  /*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */
2
2
  /*! @aiquants/auth-react-router standalone CSS — 非 Tailwind ホスト専用。ホストの Tailwind ビルドと混在させないこと */
3
- @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1}}}@layer theme{:root,:host{--color-blue-300:oklch(80.9% .105 251.813);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-indigo-500:oklch(58.5% .233 277.117);--color-indigo-600:oklch(51.1% .262 276.966);--color-white:#fff;--spacing:.25rem;--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--font-weight-bold:700;--tracking-wide:.025em;--radius-2xl:1rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1)}}@layer base,components;@layer utilities{.collapse{visibility:collapse}.fixed{position:fixed}.static{position:static}.my-4{margin-block:calc(var(--spacing) * 4)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.w-full{width:100%}.items-center{align-items:center}.justify-center{justify-content:center}.gap-3{gap:calc(var(--spacing) * 3)}.rounded-2xl{border-radius:var(--radius-2xl)}.border{border-style:var(--tw-border-style);border-width:1px}.bg-gradient-to-r{--tw-gradient-position:to right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-blue-500{--tw-gradient-from:var(--color-blue-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-indigo-500{--tw-gradient-to:var(--color-indigo-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-3{padding-block:calc(var(--spacing) * 3)}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.text-white{color:var(--color-white)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.drop-shadow{--tw-drop-shadow-size:drop-shadow(0 1px 2px var(--tw-drop-shadow-color,#0000001a)) drop-shadow(0 1px 1px var(--tw-drop-shadow-color,#0000000f));--tw-drop-shadow:drop-shadow(0 1px 2px #0000001a) drop-shadow(0 1px 1px #0000000f);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-300{--tw-duration:.3s;transition-duration:.3s}@media (hover:hover){.hover\:scale-105:hover{--tw-scale-x:105%;--tw-scale-y:105%;--tw-scale-z:105%;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:from-blue-600:hover{--tw-gradient-from:var(--color-blue-600);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.hover\:to-indigo-600:hover{--tw-gradient-to:var(--color-indigo-600);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-blue-300:focus{--tw-ring-color:var(--color-blue-300)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"<length-percentage>";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"<length-percentage>";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"<length-percentage>";inherits:false;initial-value:100%}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}
3
+ @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1}}}@layer theme{:root,:host{--color-blue-300:oklch(80.9% .105 251.813);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-indigo-500:oklch(58.5% .233 277.117);--color-indigo-600:oklch(51.1% .262 276.966);--color-white:#fff;--spacing:.25rem;--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--font-weight-bold:700;--tracking-wide:.025em;--radius-2xl:1rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1)}}@layer base,components;@layer utilities{.collapse{visibility:collapse}.fixed{position:fixed}.static{position:static}.my-4{margin-block:calc(var(--spacing) * 4)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-flex{display:inline-flex}.w-full{width:100%}.items-center{align-items:center}.justify-center{justify-content:center}.gap-3{gap:calc(var(--spacing) * 3)}.rounded-2xl{border-radius:var(--radius-2xl)}.border{border-style:var(--tw-border-style);border-width:1px}.bg-gradient-to-r{--tw-gradient-position:to right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-blue-500{--tw-gradient-from:var(--color-blue-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-indigo-500{--tw-gradient-to:var(--color-indigo-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-3{padding-block:calc(var(--spacing) * 3)}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.text-white{color:var(--color-white)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.drop-shadow{--tw-drop-shadow-size:drop-shadow(0 1px 2px var(--tw-drop-shadow-color,#0000001a)) drop-shadow(0 1px 1px var(--tw-drop-shadow-color,#0000000f));--tw-drop-shadow:drop-shadow(0 1px 2px #0000001a) drop-shadow(0 1px 1px #0000000f);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-300{--tw-duration:.3s;transition-duration:.3s}@media (hover:hover){.hover\:scale-105:hover{--tw-scale-x:105%;--tw-scale-y:105%;--tw-scale-z:105%;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:from-blue-600:hover{--tw-gradient-from:var(--color-blue-600);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.hover\:to-indigo-600:hover{--tw-gradient-to:var(--color-indigo-600);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-blue-300:focus{--tw-ring-color:var(--color-blue-300)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"<length-percentage>";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"<length-percentage>";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"<length-percentage>";inherits:false;initial-value:100%}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiquants/auth-react-router",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "React Router v7 auth adapter for @aiquants/auth-core: Google OAuth strategy factory (remix-auth), cookie session storage with DI test backdoor, per-request loader guard with token refresh + photo cache, auth route handlers, and a <GoogleForm> login button.",
5
5
  "sideEffects": [
6
6
  "**/*.css"
@@ -29,6 +29,8 @@
29
29
  "files": [
30
30
  "dist",
31
31
  "src",
32
+ "!src/**/*.spec.ts",
33
+ "!src/**/*.spec.tsx",
32
34
  "README.md",
33
35
  "LICENSE"
34
36
  ],
@@ -1,6 +1,5 @@
1
1
  import type { AuthAllowlistEntry, AuthGroup, AuthGroupMember, AuthUser } from "@aiquants/auth-core"
2
- import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"
3
- import { type PartialUserAdminLabels, resolveUserAdminLabels, type UserAdminLabels } from "./labels"
2
+ import { type PartialUserAdminLabels, resolveUserAdminLabels } from "./labels"
4
3
 
5
4
  /**
6
5
  * Server store interface for managing users, groups, memberships, and allowlists.
@@ -143,6 +142,14 @@ export function createInMemoryUserAdminStore(initial?: { users?: AuthUser[]; gro
143
142
  * independent of any particular authorization library.
144
143
  * 操作が行う CRUD 種別。特定の認可ライブラリに依存しないよう素の文字列 union で表現する。
145
144
  */
145
+ /**
146
+ * The only route-args field this module reads. React Router's full `LoaderFunctionArgs` satisfies it, so
147
+ * route wiring is unchanged — but declaring the minimum keeps callers (and tests) from fabricating fields
148
+ * that are never looked at.
149
+ * このモジュールが実際に読むルート引数の全て。React Router の完全な引数はこれを満たすため結線は不変。
150
+ */
151
+ type RouteArgs = { request: Request }
152
+
146
153
  export type UserAdminAccess = "read" | "create" | "update" | "delete"
147
154
 
148
155
  /**
@@ -183,6 +190,20 @@ const INTENT_ACCESS = new Map<string, UserAdminAccess>([
183
190
  ["removeAllowlistEntry", "delete"],
184
191
  ])
185
192
 
193
+ /**
194
+ * Neutral per-resource permission summary the admin shell renders in its header badge.
195
+ * 管理シェルのヘッダーバッジが描画する、リソース単位の中立な権限サマリ。
196
+ *
197
+ * この形はこのパッケージ自身の契約であり、特定の認可ライブラリの型を import しない
198
+ * (`AGENTS.md` のクロスパッケージ・ハードコード禁止)。`canRead` / `canWrite` / `canDelete` を持つ
199
+ * ビューであれば構造的にそのまま渡せる。
200
+ */
201
+ export type UserAdminPermission = {
202
+ canRead: boolean
203
+ canWrite: boolean
204
+ canDelete: boolean
205
+ }
206
+
186
207
  export type UserAdminAppConfig = {
187
208
  store: UserAdminStore
188
209
  /** セクション単位の部分上書き。既定は英語 (`defaultUserAdminLabels`)。 */
@@ -192,6 +213,21 @@ export type UserAdminAppConfig = {
192
213
  * 必須。省略を許すとユーザー名簿と全更新操作が無防備に露出するため、型で強制する。
193
214
  */
194
215
  guards: UserAdminGuards
216
+ /**
217
+ * Per-request self-permission lookup for the header annotation. Resolving to a map of
218
+ * {@link UserAdminPermission} shows the badge; rejecting shows an explicit "unknown" badge — never a
219
+ * permissive one. Any authz library whose view carries `canRead` / `canWrite` / `canDelete` satisfies it.
220
+ * ヘッダー注釈に自分の実効権限を表示するためのリクエスト別権限取得関数。失敗時は「権限不明」表示。
221
+ */
222
+ getMyPermissions?: (request: Request) => Promise<Record<string, UserAdminPermission | undefined>>
223
+ /**
224
+ * Display names for the resource keys the badge shows, e.g. `{ authz_admin: "ユーザー管理 UI" }`.
225
+ * バッジに出すリソースキーの表示名。未指定のキーはキー文字列をそのまま表示。
226
+ *
227
+ * 表示名をこのパッケージ側に埋め込むと、別パッケージのリソースキーを名指しすることになる
228
+ * (`AGENTS.md` のクロスパッケージ・ハードコード禁止)。中立な差し込み口として受け取る。
229
+ */
230
+ resourceLabels?: Record<string, string>
195
231
  }
196
232
 
197
233
  /**
@@ -238,26 +274,40 @@ function optionalText(formData: FormData, field: string): string | undefined {
238
274
  export function createUserAdminApp(config: UserAdminAppConfig) {
239
275
  const labels = resolveUserAdminLabels(config.labels)
240
276
 
241
- async function loader({ request }: LoaderFunctionArgs) {
277
+ async function loader({ request }: RouteArgs) {
242
278
  // 読取ガード: 拒否は throw されるためここで処理が打ち切られる
243
279
  await config.guards.requireAccess(request, { action: "read", intent: "loader" })
244
280
  const url = new URL(request.url)
245
281
  const pathSegments = url.pathname.split("/").filter(Boolean)
246
282
  const segment = pathSegments[pathSegments.length - 1] ?? "users"
247
283
 
248
- const [users, groups, groupMembers, allowlist] = await Promise.all([config.store.listUsers(), config.store.listGroups(), config.store.listGroupMembers(), config.store.listAllowlist()])
284
+ const [users, groups, groupMembers, allowlist, myPermissions] = await Promise.all([
285
+ config.store.listUsers(),
286
+ config.store.listGroups(),
287
+ config.store.listGroupMembers(),
288
+ config.store.listAllowlist(),
289
+ // 取得できない場合は null (= 権限不明)。空マップに倒すと「付与ゼロ」と区別できなくなる
290
+ config.getMyPermissions
291
+ ? config.getMyPermissions(request).catch((e) => {
292
+ console.error("[@aiquants/auth-react-router] getMyPermissions failed; rendering the permission badge as unknown:", e)
293
+ return null
294
+ })
295
+ : Promise.resolve(null),
296
+ ])
249
297
 
250
298
  return {
299
+ resourceLabels: config.resourceLabels,
251
300
  segment: ["groups", "group_members", "allowlist"].includes(segment) ? segment : "users",
252
301
  users,
253
302
  groups,
254
303
  groupMembers,
255
304
  allowlist,
305
+ myPermissions,
256
306
  labels,
257
307
  }
258
308
  }
259
309
 
260
- async function action({ request }: ActionFunctionArgs) {
310
+ async function action({ request }: RouteArgs) {
261
311
  // intent の判定に FormData が要るため、ここでだけ本文を先に読む。
262
312
  // 未知 intent は「何も実行しない」ため、ガード前に読んでも権限判断には影響しない。
263
313
  const formData = await request.formData()
package/src/admin/ui.tsx CHANGED
@@ -1,20 +1,52 @@
1
1
  import type React from "react"
2
2
  import { Link, useLoaderData } from "react-router"
3
3
  import { defaultUserAdminLabels, type UserAdminLabels } from "./labels"
4
+ import type { UserAdminPermission } from "./server"
4
5
 
5
6
  export type UserAdminShellProps = {
6
7
  children?: React.ReactNode
7
8
  renderHeader?: (props: { title: string; annotation: React.ReactNode }) => React.ReactNode
8
9
  }
9
10
 
11
+ /**
12
+ * Render one resource's permission summary as a compact pill.
13
+ * 1 リソース分の権限サマリをコンパクトなタグとして描画するコンポーネント。
14
+ */
15
+ function GroupPill({ label, summary }: { label: string; summary: { text: string; color: string; bgColor: string; borderColor: string } }) {
16
+ return (
17
+ <span
18
+ style={{
19
+ display: "inline-flex",
20
+ alignItems: "center",
21
+ gap: "0.18rem",
22
+ fontSize: "0.65rem",
23
+ padding: "0.08rem 0.35rem",
24
+ borderRadius: "9999px",
25
+ backgroundColor: summary.bgColor,
26
+ color: summary.color,
27
+ border: `1px solid ${summary.borderColor}`,
28
+ fontWeight: 600,
29
+ lineHeight: "1.2",
30
+ }}>
31
+ <span>{label}:</span>
32
+ <span>{summary.text}</span>
33
+ </span>
34
+ )
35
+ }
36
+
10
37
  /**
11
38
  * Shell layout for user administration pages with header annotation delegation.
12
39
  * ヘッダー注釈委譲を備えたユーザー管理ページのシェルレイアウト。
13
40
  */
14
41
  export function UserAdminShell({ children, renderHeader }: UserAdminShellProps) {
15
- const data = useLoaderData() as { segment?: string; labels?: UserAdminLabels } | undefined
42
+ const data = useLoaderData() as { segment?: string; labels?: UserAdminLabels; myPermissions?: Record<string, UserAdminPermission | undefined> | null; resourceLabels?: Record<string, string> } | undefined
16
43
  const segment = data?.segment ?? "users"
17
44
  const labels = data?.labels ?? defaultUserAdminLabels
45
+ // null = 未結線または取得失敗。空 {} も「分からない」扱いで、いずれも許可として描画しない
46
+ const myPermissions = data?.myPermissions ?? null
47
+ // リソースキーの表示名は呼び出し側が差し込む (キーをこのパッケージに埋め込まない)
48
+ const resourceLabels = data?.resourceLabels ?? {}
49
+ const labelFor = (resourceKey: string) => (Object.hasOwn(resourceLabels, resourceKey) ? resourceLabels[resourceKey] : resourceKey)
18
50
 
19
51
  const tabs = [
20
52
  { key: "users", label: labels.tabs.users, href: "/users" },
@@ -23,9 +55,42 @@ export function UserAdminShell({ children, renderHeader }: UserAdminShellProps)
23
55
  { key: "allowlist", label: labels.tabs.allowlist, href: "/users/allowlist" },
24
56
  ]
25
57
 
58
+ const permEntries = Object.entries(myPermissions ?? {})
59
+
60
+ /**
61
+ * Pick the badge text and colours for one resource, never rendering "unknown" as "allowed".
62
+ * 1 リソース分のバッジ文言と配色を決める処理。**未知を許可として描かない** ことが要件。
63
+ *
64
+ * 以前は権限マップが空のとき「編集可」の固定値を描いていたため、ポート未結線・取得失敗・
65
+ * 付与ゼロのいずれでも「全操作できます」と表示されていた (実際には操作するとガードで拒否される)。
66
+ */
67
+ const getSummary = (perm: UserAdminPermission | undefined) => {
68
+ if (perm?.canWrite) {
69
+ return { text: "編集可", color: "#047857", bgColor: "#ecfdf5", borderColor: "#a7f3d0" }
70
+ }
71
+ if (perm?.canRead) {
72
+ return { text: "閲覧のみ", color: "#1d4ed8", bgColor: "#eff6ff", borderColor: "#bfdbfe" }
73
+ }
74
+ return { text: "権限なし", color: "#64748b", bgColor: "#f1f5f9", borderColor: "#cbd5e1" }
75
+ }
76
+ const unavailableSummary = { text: "権限不明", color: "#92400e", bgColor: "#fffbeb", borderColor: "#fde68a" }
77
+
26
78
  const annotation = (
27
- <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }} data-testid="user-admin-header-annotation">
79
+ <div style={{ display: "flex", alignItems: "center", gap: "0.4rem" }} data-testid="user-admin-header-annotation">
28
80
  <span style={{ fontSize: "0.75rem", backgroundColor: "#e0f2fe", color: "#0369a1", padding: "2px 8px", borderRadius: "12px", fontWeight: 600 }}>{labels.heading}</span>
81
+
82
+ <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>
84
+ <span style={{ display: "inline-flex", alignItems: "center", gap: "0.18rem" }}>
85
+ {permEntries.length === 0 ? (
86
+ <span data-testid="user-admin-permission-unavailable">
87
+ <GroupPill label={labels.heading} summary={unavailableSummary} />
88
+ </span>
89
+ ) : (
90
+ permEntries.map(([resKey, perm]) => <GroupPill key={resKey} label={labelFor(resKey)} summary={getSummary(perm)} />)
91
+ )}
92
+ </span>
93
+ </div>
29
94
  </div>
30
95
  )
31
96
 
@@ -1,300 +0,0 @@
1
- import { describe, expect, it } from "vitest"
2
- import { createInMemoryUserAdminStore, createUserAdminApp, type UserAdminGuards } from "./server"
3
-
4
- /** テスト用の常時許可ガード(ガード自体の検証は専用ケースで行う)。 */
5
- const allowAll = (): UserAdminGuards => ({ requireAccess: () => {} })
6
-
7
- /** intent + フィールドを FormData に載せて action を叩くヘルパ。 */
8
- function runActionOn(app: ReturnType<typeof createUserAdminApp>) {
9
- return (intent: string, fields: Record<string, string>) => {
10
- const formData = new FormData()
11
- formData.append("intent", intent)
12
- for (const [key, val] of Object.entries(fields)) formData.append(key, val)
13
- return app.action({
14
- request: new Request("http://localhost/users", { method: "POST", body: formData }),
15
- params: {},
16
- context: {},
17
- } as never)
18
- }
19
- }
20
-
21
- describe("UserAdminStore & createUserAdminApp", () => {
22
- it("manages users correctly in memory store and throws on non-existent ids", async () => {
23
- const store = createInMemoryUserAdminStore({ users: [] })
24
-
25
- const created = await store.createUser({ displayName: "テスト 太郎", email: "test@example.com" })
26
- expect(created.id).toBe(1)
27
- expect(created.displayName).toBe("テスト 太郎")
28
- expect(created.isActive).toBe(true)
29
-
30
- const updated = await store.updateUser(1, { displayName: "テスト 次郎", email: "updated@example.com" })
31
- expect(updated.displayName).toBe("テスト 次郎")
32
- expect(updated.email).toBe("updated@example.com")
33
-
34
- const toggled = await store.toggleUserActive(1, false)
35
- expect(toggled.isActive).toBe(false)
36
-
37
- await expect(store.updateUser(999, { displayName: "a", email: "b" })).rejects.toThrow("User not found: 999")
38
- await expect(store.toggleUserActive(999, true)).rejects.toThrow("User not found: 999")
39
-
40
- const list = await store.listUsers()
41
- expect(list).toHaveLength(1)
42
- })
43
-
44
- it("manages groups and memberships correctly and throws on non-existent group ids", async () => {
45
- const store = createInMemoryUserAdminStore({ users: [{ id: 10, displayName: "U10", email: "u10@ex.com", isActive: true }], groups: [], groupMembers: [] })
46
-
47
- const group = await store.createGroup({ groupKey: "qa-team", name: "QAチーム", description: "品質保証" })
48
- expect(group.id).toBe(1)
49
- expect(group.groupKey).toBe("qa-team")
50
-
51
- const updatedGroup = await store.updateGroup(1, { name: "QA・テストチーム", description: "品質管理全般" })
52
- expect(updatedGroup.name).toBe("QA・テストチーム")
53
-
54
- await expect(store.updateGroup(999, { name: "x" })).rejects.toThrow("Group not found: 999")
55
-
56
- const member1 = await store.addGroupMember(1, 10)
57
- expect(member1.groupId).toBe(1)
58
- expect(member1.userId).toBe(10)
59
-
60
- // Idempotent duplicate add
61
- const member2 = await store.addGroupMember(1, 10)
62
- expect(member2).toEqual(member1)
63
-
64
- const members = await store.listGroupMembers()
65
- expect(members).toHaveLength(1)
66
-
67
- await store.removeGroupMember(1, 10)
68
- expect(await store.listGroupMembers()).toHaveLength(0)
69
-
70
- await store.deleteGroup(1)
71
- expect(await store.listGroups()).toHaveLength(0)
72
- })
73
-
74
- it("manages allowlist entries correctly in memory store", async () => {
75
- const store = createInMemoryUserAdminStore({ allowlist: [] })
76
-
77
- const entry = await store.addAllowlistEntry({ pattern: "@domain.com", type: "domain", description: "許可ドメイン" })
78
- expect(entry.id).toBe(1)
79
- expect(entry.pattern).toBe("@domain.com")
80
-
81
- expect(await store.listAllowlist()).toHaveLength(1)
82
-
83
- await store.removeAllowlistEntry(1)
84
- expect(await store.listAllowlist()).toHaveLength(0)
85
- })
86
-
87
- it("handles all loader route segments via createUserAdminApp", async () => {
88
- const store = createInMemoryUserAdminStore()
89
- const app = createUserAdminApp({ store, guards: allowAll() })
90
-
91
- const segments = ["users", "groups", "group_members", "allowlist", "unknown_path"]
92
- for (const seg of segments) {
93
- const loaderRes = await app.loader({
94
- request: new Request(`http://localhost/users/${seg}`),
95
- params: {},
96
- context: {},
97
- })
98
- if (seg === "unknown_path") {
99
- expect(loaderRes.segment).toBe("users")
100
- } else {
101
- expect(loaderRes.segment).toBe(seg)
102
- }
103
- }
104
- })
105
-
106
- it("handles all action intents and PERSISTS each mutation to the store", async () => {
107
- const store = createInMemoryUserAdminStore()
108
- const app = createUserAdminApp({ store, guards: allowAll() })
109
- const run = runActionOn(app)
110
-
111
- // createUser — 戻り値だけでなく実際の永続結果を検証する
112
- expect(await run("createUser", { displayName: "John", email: "john@example.com" })).toEqual({ ok: true })
113
- const created = (await store.listUsers()).find((u) => u.email === "john@example.com")
114
- expect(created).toMatchObject({ displayName: "John", isActive: true })
115
-
116
- expect(await run("updateUser", { userId: String(created?.id), displayName: "John Updated", email: "john.updated@example.com" })).toEqual({ ok: true })
117
- expect((await store.listUsers()).find((u) => u.id === created?.id)).toMatchObject({ displayName: "John Updated", email: "john.updated@example.com" })
118
-
119
- expect(await run("toggleUserActive", { userId: String(created?.id), isActive: "false" })).toEqual({ ok: true })
120
- expect((await store.listUsers()).find((u) => u.id === created?.id)?.isActive).toBe(false)
121
-
122
- expect(await run("createGroup", { groupKey: "sec-team", name: "Security", description: "SecOps" })).toEqual({ ok: true })
123
- const group = (await store.listGroups()).find((g) => g.groupKey === "sec-team")
124
- expect(group).toMatchObject({ name: "Security", description: "SecOps" })
125
-
126
- expect(await run("updateGroup", { groupId: String(group?.id), name: "Security Team", description: "Updated" })).toEqual({ ok: true })
127
- expect((await store.listGroups()).find((g) => g.id === group?.id)).toMatchObject({ name: "Security Team", description: "Updated" })
128
-
129
- expect(await run("addGroupMember", { groupId: String(group?.id), userId: String(created?.id) })).toEqual({ ok: true })
130
- expect((await store.listGroupMembers()).some((m) => m.groupId === group?.id && m.userId === created?.id)).toBe(true)
131
-
132
- expect(await run("removeGroupMember", { groupId: String(group?.id), userId: String(created?.id) })).toEqual({ ok: true })
133
- expect((await store.listGroupMembers()).some((m) => m.groupId === group?.id && m.userId === created?.id)).toBe(false)
134
-
135
- expect(await run("addAllowlistEntry", { pattern: "@sec.com", type: "domain", description: "Sec" })).toEqual({ ok: true })
136
- expect(await run("addAllowlistEntry", { pattern: "user@sec.com", type: "email" })).toEqual({ ok: true })
137
- const allowlist = await store.listAllowlist()
138
- expect(allowlist.find((a) => a.pattern === "@sec.com")?.type).toBe("domain")
139
- expect(allowlist.find((a) => a.pattern === "user@sec.com")?.type).toBe("email")
140
-
141
- const removable = allowlist.find((a) => a.pattern === "@sec.com")
142
- expect(await run("removeAllowlistEntry", { entryId: String(removable?.id) })).toEqual({ ok: true })
143
- expect((await store.listAllowlist()).some((a) => a.pattern === "@sec.com")).toBe(false)
144
-
145
- // unknown intent
146
- expect(await run("unknownIntent", {})).toEqual({ ok: false, error: "Unknown intent: unknownIntent" })
147
- })
148
-
149
- it("deleteGroup cascades: the group's memberships are removed too (no orphan rows)", async () => {
150
- const store = createInMemoryUserAdminStore({
151
- users: [{ id: 10, displayName: "U10", email: "u10@ex.com", isActive: true }],
152
- groups: [{ id: 5, groupKey: "g5", name: "G5", isActive: true }],
153
- groupMembers: [{ id: 1, groupId: 5, userId: 10 }],
154
- })
155
- const app = createUserAdminApp({ store, guards: allowAll() })
156
-
157
- expect(await runActionOn(app)("deleteGroup", { groupId: "5" })).toEqual({ ok: true })
158
- expect((await store.listGroups()).some((g) => g.id === 5)).toBe(false)
159
- // 参照整合性: グループ削除で所属行も消えること (孤立行ゼロ)
160
- expect((await store.listGroupMembers()).some((m) => m.groupId === 5)).toEqual(false)
161
- })
162
-
163
- it("denies every read and write when the guards throw (fail-closed mount)", async () => {
164
- const store = createInMemoryUserAdminStore()
165
- const denied = new Error("403")
166
- const app = createUserAdminApp({
167
- store,
168
- guards: {
169
- requireAccess: () => {
170
- throw denied
171
- },
172
- },
173
- })
174
-
175
- await expect(app.loader({ request: new Request("http://localhost/users"), params: {}, context: {} } as never)).rejects.toThrow("403")
176
-
177
- const before = await store.listUsers()
178
- // ガード拒否はそのまま伝播する ({ok:false} に握り潰さない)
179
- await expect(runActionOn(app)("createUser", { displayName: "Attacker", email: "attacker@evil.example" })).rejects.toThrow("403")
180
- // 拒否時にストアが一切変化しないこと
181
- expect(await store.listUsers()).toEqual(before)
182
- })
183
-
184
- it("guards run BEFORE the body is read, and receive the incoming request", async () => {
185
- const store = createInMemoryUserAdminStore()
186
- const seen: string[] = []
187
- const app = createUserAdminApp({
188
- store,
189
- guards: {
190
- requireAccess: (request, ctx) => {
191
- seen.push(`${ctx.action}:${ctx.intent}:${new URL(request.url).pathname}`)
192
- },
193
- },
194
- })
195
-
196
- await app.loader({ request: new Request("http://localhost/users/groups"), params: {}, context: {} } as never)
197
- await runActionOn(app)("createUser", { displayName: "A", email: "a@ex.com" })
198
- expect(seen).toEqual(["read:loader:/users/groups", "create:createUser:/users"])
199
- })
200
-
201
- it("requires the CRUD verb each intent actually performs (delete is not covered by update)", async () => {
202
- const store = createInMemoryUserAdminStore()
203
- const asked: Array<{ action: string; intent: string }> = []
204
- const app = createUserAdminApp({ store, guards: { requireAccess: (_r, ctx) => void asked.push({ ...ctx }) } })
205
- const run = runActionOn(app)
206
-
207
- await run("createUser", { displayName: "A", email: "a@ex.com" })
208
- await run("updateGroup", { groupId: "1", name: "N" })
209
- await run("deleteGroup", { groupId: "1" })
210
- await run("addGroupMember", { groupId: "2", userId: "1" })
211
- await run("removeGroupMember", { groupId: "2", userId: "1" })
212
- await run("addAllowlistEntry", { pattern: "@x.com", type: "domain" })
213
- await run("removeAllowlistEntry", { entryId: "1" })
214
-
215
- expect(asked).toEqual([
216
- { action: "create", intent: "createUser" },
217
- { action: "update", intent: "updateGroup" },
218
- { action: "delete", intent: "deleteGroup" },
219
- { action: "create", intent: "addGroupMember" },
220
- { action: "delete", intent: "removeGroupMember" },
221
- { action: "create", intent: "addAllowlistEntry" },
222
- { action: "delete", intent: "removeAllowlistEntry" },
223
- ])
224
- })
225
-
226
- it("an update-only principal cannot delete (destructive intents are blocked, update ones pass)", async () => {
227
- const store = createInMemoryUserAdminStore({ groups: [{ id: 1, groupKey: "g", name: "G", isActive: true }], users: [], groupMembers: [], allowlist: [] })
228
- // update だけを許す主体を模す
229
- const app = createUserAdminApp({
230
- store,
231
- guards: {
232
- requireAccess: (_r, { action }) => {
233
- if (action !== "update" && action !== "read") throw new Error(`forbidden: ${action}`)
234
- },
235
- },
236
- })
237
- const run = runActionOn(app)
238
-
239
- await expect(run("deleteGroup", { groupId: "1" })).rejects.toThrow("forbidden: delete")
240
- expect((await store.listGroups()).some((g) => g.id === 1)).toBe(true) // 削除されていない
241
- await expect(run("createUser", { displayName: "A", email: "a@ex.com" })).rejects.toThrow("forbidden: create")
242
- // update 相当は通る
243
- expect(await run("updateGroup", { groupId: "1", name: "G2" })).toEqual({ ok: true })
244
- })
245
-
246
- it("an unknown intent never reaches the guard nor the store", async () => {
247
- const store = createInMemoryUserAdminStore()
248
- let called = 0
249
- const app = createUserAdminApp({ store, guards: { requireAccess: () => void called++ } })
250
- expect(await runActionOn(app)("__proto__", {})).toEqual({ ok: false, error: "Unknown intent: __proto__" })
251
- expect(await runActionOn(app)("constructor", {})).toEqual({ ok: false, error: "Unknown intent: constructor" })
252
- expect(called).toBe(0)
253
- })
254
-
255
- it("rejects malformed ids and blank required fields instead of writing garbage", async () => {
256
- const store = createInMemoryUserAdminStore({ users: [], groups: [], groupMembers: [], allowlist: [] })
257
- const run = runActionOn(createUserAdminApp({ store, guards: allowAll() }))
258
-
259
- // 欠落 / 非数値 / 0 以下の ID は書き込む前に弾き、理由を UI へ返す
260
- expect(await run("updateUser", { displayName: "x", email: "y" })).toMatchObject({ ok: false, error: expect.stringMatching(/userId/) })
261
- expect(await run("deleteGroup", { groupId: "abc" })).toMatchObject({ ok: false, error: expect.stringMatching(/groupId/) })
262
- expect(await run("addGroupMember", { groupId: "1", userId: "0" })).toMatchObject({ ok: false, error: expect.stringMatching(/userId/) })
263
- expect(await run("removeAllowlistEntry", { entryId: "-1" })).toMatchObject({ ok: false, error: expect.stringMatching(/entryId/) })
264
- // 必須文字列の空送信も弾く
265
- expect(await run("createUser", { displayName: " ", email: "a@ex.com" })).toMatchObject({ ok: false, error: expect.stringMatching(/displayName/) })
266
- expect(await run("createGroup", { groupKey: "k", name: "" })).toMatchObject({ ok: false, error: expect.stringMatching(/name/) })
267
- // 明示的な true/false 以外の isActive も拒否 (破壊側を既定にしない)
268
- expect(await run("toggleUserActive", { userId: "1" })).toMatchObject({ ok: false, error: expect.stringMatching(/isActive/) })
269
-
270
- expect(await store.listUsers()).toHaveLength(0)
271
- expect(await store.listGroups()).toHaveLength(0)
272
- expect(await store.listGroupMembers()).toHaveLength(0)
273
- expect(await store.listAllowlist()).toHaveLength(0)
274
- })
275
- })
276
-
277
- describe("createUserAdminApp — ストア由来のエラーを UI へ届ける", () => {
278
- it("ストアが投げた業務エラー (ロックアウト理由など) を {ok:false,error} で返す", async () => {
279
- const store = createInMemoryUserAdminStore()
280
- store.deleteGroup = async () => {
281
- throw new Error("グループの削除は実行できません: 最後の管理者を失うためです")
282
- }
283
- const app = createUserAdminApp({ store, guards: { requireAccess: () => {} } })
284
- expect(await runActionOn(app)("deleteGroup", { groupId: "1" })).toEqual({ ok: false, error: "グループの削除は実行できません: 最後の管理者を失うためです" })
285
- })
286
-
287
- it("ガードの拒否 (Response) は握り潰さずそのまま伝播する", async () => {
288
- const store = createInMemoryUserAdminStore()
289
- const denied = new Response("Forbidden", { status: 403 })
290
- const app = createUserAdminApp({
291
- store,
292
- guards: {
293
- requireAccess: () => {
294
- throw denied
295
- },
296
- },
297
- })
298
- await expect(runActionOn(app)("deleteGroup", { groupId: "1" })).rejects.toBe(denied)
299
- })
300
- })