@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.
- package/README.md +22 -0
- package/dist/admin.d.mts +42 -4
- package/dist/admin.d.ts +42 -4
- 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/dist/styles/auth-react-router.standalone.css +1 -1
- package/package.json +3 -1
- package/src/admin/server.ts +55 -5
- package/src/admin/ui.tsx +67 -2
- package/src/admin/server.spec.ts +0 -300
- package/src/admin/views.spec.tsx +0 -307
- package/src/server/authenticate-in-loader.spec.ts +0 -152
- package/src/server/authenticate.spec.ts +0 -146
- package/src/server/google-strategy-impl.spec.ts +0 -116
- package/src/server/google-strategy.spec.ts +0 -271
- package/src/server/handlers.spec.ts +0 -154
- package/src/server/session.spec.ts +0 -165
|
@@ -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.
|
|
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
|
],
|
package/src/admin/server.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { AuthAllowlistEntry, AuthGroup, AuthGroupMember, AuthUser } from "@aiquants/auth-core"
|
|
2
|
-
import type
|
|
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 }:
|
|
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([
|
|
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 }:
|
|
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.
|
|
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
|
|
package/src/admin/server.spec.ts
DELETED
|
@@ -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
|
-
})
|