@aiquants/auth-react-router 0.4.1 → 0.5.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.
@@ -0,0 +1,345 @@
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"
4
+
5
+ /**
6
+ * Server store interface for managing users, groups, memberships, and allowlists.
7
+ * ユーザー・グループ・所属・アロワリストを管理するサーバー層ストアのインターフェース。
8
+ */
9
+ export type UserAdminStore = {
10
+ listUsers: () => Promise<AuthUser[]>
11
+ createUser: (data: { displayName: string; email: string }) => Promise<AuthUser>
12
+ updateUser: (id: number, data: { displayName: string; email: string }) => Promise<AuthUser>
13
+ toggleUserActive: (id: number, isActive: boolean) => Promise<AuthUser>
14
+
15
+ listGroups: () => Promise<AuthGroup[]>
16
+ createGroup: (data: { groupKey: string; name: string; description?: string }) => Promise<AuthGroup>
17
+ updateGroup: (id: number, data: { name: string; description?: string }) => Promise<AuthGroup>
18
+ deleteGroup: (id: number) => Promise<void>
19
+
20
+ listGroupMembers: () => Promise<AuthGroupMember[]>
21
+ addGroupMember: (groupId: number, userId: number) => Promise<AuthGroupMember>
22
+ removeGroupMember: (groupId: number, userId: number) => Promise<void>
23
+
24
+ listAllowlist: () => Promise<AuthAllowlistEntry[]>
25
+ addAllowlistEntry: (data: { pattern: string; type: "email" | "domain"; description?: string }) => Promise<AuthAllowlistEntry>
26
+ removeAllowlistEntry: (id: number) => Promise<void>
27
+ }
28
+
29
+ /**
30
+ * Creates an in-memory UserAdminStore initialized with optional seed data.
31
+ * シードデータで初期化されるメモリ上の UserAdminStore を作成する。
32
+ */
33
+ export function createInMemoryUserAdminStore(initial?: { users?: AuthUser[]; groups?: AuthGroup[]; groupMembers?: AuthGroupMember[]; allowlist?: AuthAllowlistEntry[] }): UserAdminStore {
34
+ const users: AuthUser[] = initial?.users ?? [
35
+ { id: 1, email: "admin@example.com", displayName: "システム管理者", isActive: true },
36
+ { id: 2, email: "user1@example.com", displayName: "開発 太郎", isActive: true },
37
+ { id: 3, email: "user2@example.com", displayName: "営業 花子", isActive: true },
38
+ ]
39
+ let groups: AuthGroup[] = initial?.groups ?? [
40
+ { id: 1, groupKey: "dev-team", name: "開発チーム", description: "システム開発担当グループ", isActive: true },
41
+ { id: 2, groupKey: "sales-dept", name: "営業部", description: "営業ソリューション担当", isActive: true },
42
+ ]
43
+ let groupMembers: AuthGroupMember[] = initial?.groupMembers ?? [
44
+ { id: 1, groupId: 1, userId: 2 },
45
+ { id: 2, groupId: 2, userId: 3 },
46
+ ]
47
+ let allowlist: AuthAllowlistEntry[] = initial?.allowlist ?? [
48
+ { id: 1, pattern: "admin@example.com", type: "email", description: "初期管理者" },
49
+ { id: 2, pattern: "@example.com", type: "domain", description: "社内ドメイン" },
50
+ ]
51
+
52
+ return {
53
+ async listUsers() {
54
+ return [...users]
55
+ },
56
+ async createUser(data) {
57
+ const newUser: AuthUser = {
58
+ id: Math.max(...users.map((u) => u.id), 0) + 1,
59
+ email: data.email,
60
+ displayName: data.displayName,
61
+ isActive: true,
62
+ }
63
+ users.push(newUser)
64
+ return newUser
65
+ },
66
+ async updateUser(id, data) {
67
+ const idx = users.findIndex((u) => u.id === id)
68
+ if (idx === -1) throw new Error(`User not found: ${id}`)
69
+ users[idx] = { ...users[idx], displayName: data.displayName, email: data.email }
70
+ return users[idx]
71
+ },
72
+ async toggleUserActive(id, isActive) {
73
+ const idx = users.findIndex((u) => u.id === id)
74
+ if (idx === -1) throw new Error(`User not found: ${id}`)
75
+ users[idx] = { ...users[idx], isActive }
76
+ return users[idx]
77
+ },
78
+
79
+ async listGroups() {
80
+ return [...groups]
81
+ },
82
+ async createGroup(data) {
83
+ const newGroup: AuthGroup = {
84
+ id: Math.max(...groups.map((g) => g.id), 0) + 1,
85
+ groupKey: data.groupKey,
86
+ name: data.name,
87
+ description: data.description,
88
+ isActive: true,
89
+ }
90
+ groups.push(newGroup)
91
+ return newGroup
92
+ },
93
+ async updateGroup(id, data) {
94
+ const idx = groups.findIndex((g) => g.id === id)
95
+ if (idx === -1) throw new Error(`Group not found: ${id}`)
96
+ groups[idx] = { ...groups[idx], name: data.name, description: data.description }
97
+ return groups[idx]
98
+ },
99
+ async deleteGroup(id) {
100
+ groups = groups.filter((g) => g.id !== id)
101
+ groupMembers = groupMembers.filter((m) => m.groupId !== id)
102
+ },
103
+
104
+ async listGroupMembers() {
105
+ return [...groupMembers]
106
+ },
107
+ async addGroupMember(groupId, userId) {
108
+ const existing = groupMembers.find((m) => m.groupId === groupId && m.userId === userId)
109
+ if (existing) return existing
110
+ const newMember: AuthGroupMember = {
111
+ id: Math.max(...groupMembers.map((m) => m.id), 0) + 1,
112
+ groupId,
113
+ userId,
114
+ }
115
+ groupMembers.push(newMember)
116
+ return newMember
117
+ },
118
+ async removeGroupMember(groupId, userId) {
119
+ groupMembers = groupMembers.filter((m) => !(m.groupId === groupId && m.userId === userId))
120
+ },
121
+
122
+ async listAllowlist() {
123
+ return [...allowlist]
124
+ },
125
+ async addAllowlistEntry(data) {
126
+ const newEntry: AuthAllowlistEntry = {
127
+ id: Math.max(...allowlist.map((a) => a.id), 0) + 1,
128
+ pattern: data.pattern,
129
+ type: data.type,
130
+ description: data.description,
131
+ }
132
+ allowlist.push(newEntry)
133
+ return newEntry
134
+ },
135
+ async removeAllowlistEntry(id) {
136
+ allowlist = allowlist.filter((a) => a.id !== id)
137
+ },
138
+ }
139
+ }
140
+
141
+ /**
142
+ * The CRUD verb an operation performs. Deliberately a plain string union so this package stays
143
+ * independent of any particular authorization library.
144
+ * 操作が行う CRUD 種別。特定の認可ライブラリに依存しないよう素の文字列 union で表現する。
145
+ */
146
+ export type UserAdminAccess = "read" | "create" | "update" | "delete"
147
+
148
+ /**
149
+ * Access guard the user-admin surface is mounted behind. Deny by throwing
150
+ * (a `Response`/`redirect` or an `Error`); returning normally means "allow".
151
+ *
152
+ * ユーザー管理面をマウントする際の必須アクセスガード。拒否は throw で表現する
153
+ * (`Response` / `redirect` / `Error`)。正常 return は「許可」を意味する。
154
+ *
155
+ * 操作ごとの CRUD 種別を受け取るため、呼び出し側は「削除だけ別権限」といった細分化ができる。
156
+ * `intent` は監査ログ用の補助情報(認可判断は `action` で行うこと)。
157
+ */
158
+ export type UserAdminGuards = {
159
+ /**
160
+ * 許可は「正常 return」、拒否は「throw」で表す。返り値は判定に使わない (`void` 契約)。
161
+ * 述語のつもりで `false` を返す実装を書くと素通りするため、返り値型で明示的に禁じる。
162
+ */
163
+ requireAccess: (request: Request, context: { action: UserAdminAccess; intent: string }) => Promise<void> | void
164
+ }
165
+
166
+ /**
167
+ * Maps each action intent to the CRUD verb it actually performs.
168
+ * 各 intent が実際に行う操作の CRUD 種別への対応表。
169
+ *
170
+ * 割当の付与・解除は行の追加・削除であるため、認可管理 UI (`/authz`) の
171
+ * `assignUserRole` = create / `removeUserRole` = delete と同じ粒度に揃える。
172
+ */
173
+ const INTENT_ACCESS = new Map<string, UserAdminAccess>([
174
+ ["createUser", "create"],
175
+ ["updateUser", "update"],
176
+ ["toggleUserActive", "update"],
177
+ ["createGroup", "create"],
178
+ ["updateGroup", "update"],
179
+ ["deleteGroup", "delete"],
180
+ ["addGroupMember", "create"],
181
+ ["removeGroupMember", "delete"],
182
+ ["addAllowlistEntry", "create"],
183
+ ["removeAllowlistEntry", "delete"],
184
+ ])
185
+
186
+ export type UserAdminAppConfig = {
187
+ store: UserAdminStore
188
+ /** セクション単位の部分上書き。既定は英語 (`defaultUserAdminLabels`)。 */
189
+ labels?: PartialUserAdminLabels
190
+ /**
191
+ * Required — omitting a guard would expose the whole user directory and its write actions.
192
+ * 必須。省略を許すとユーザー名簿と全更新操作が無防備に露出するため、型で強制する。
193
+ */
194
+ guards: UserAdminGuards
195
+ }
196
+
197
+ /**
198
+ * Reads a required positive integer id from submitted form data.
199
+ * 送信フォームから必須の正整数 ID を読み取る処理。
200
+ *
201
+ * @throws when the field is missing, non-numeric, or not a positive integer.
202
+ * 欠落・非数値・正整数でない場合は throw。
203
+ */
204
+ function requireId(formData: FormData, field: string): number {
205
+ const raw = formData.get(field)
206
+ const n = Number(raw)
207
+ // NaN / 小数 / 0 以下は不正な ID として弾く (Number(null) === 0 の取り違えも防ぐ)
208
+ if (raw === null || String(raw).trim() === "" || !Number.isInteger(n) || n <= 0) {
209
+ throw new Error(`Invalid or missing "${field}"`)
210
+ }
211
+ return n
212
+ }
213
+
214
+ /**
215
+ * Reads a required non-empty trimmed string from submitted form data.
216
+ * 送信フォームから必須の非空文字列 (トリム済み) を読み取る処理。
217
+ *
218
+ * @throws when the field is missing or blank. 欠落・空白のみの場合は throw。
219
+ */
220
+ function requireText(formData: FormData, field: string): string {
221
+ const value = String(formData.get(field) ?? "").trim()
222
+ if (value === "") throw new Error(`Invalid or missing "${field}"`)
223
+ return value
224
+ }
225
+
226
+ /** Reads an optional trimmed string; blank becomes `undefined`. 任意文字列の読み取り (空は undefined)。 */
227
+ function optionalText(formData: FormData, field: string): string | undefined {
228
+ const value = String(formData.get(field) ?? "").trim()
229
+ return value === "" ? undefined : value
230
+ }
231
+
232
+ /**
233
+ * Creates React Router loader & action handler for user administration.
234
+ * ユーザー管理用 React Router の loader および action ハンドラーを作成する。
235
+ *
236
+ * `config.guards` は必須。loader / action の双方が、処理を始める前にガードを通過させる。
237
+ */
238
+ export function createUserAdminApp(config: UserAdminAppConfig) {
239
+ const labels = resolveUserAdminLabels(config.labels)
240
+
241
+ async function loader({ request }: LoaderFunctionArgs) {
242
+ // 読取ガード: 拒否は throw されるためここで処理が打ち切られる
243
+ await config.guards.requireAccess(request, { action: "read", intent: "loader" })
244
+ const url = new URL(request.url)
245
+ const pathSegments = url.pathname.split("/").filter(Boolean)
246
+ const segment = pathSegments[pathSegments.length - 1] ?? "users"
247
+
248
+ const [users, groups, groupMembers, allowlist] = await Promise.all([config.store.listUsers(), config.store.listGroups(), config.store.listGroupMembers(), config.store.listAllowlist()])
249
+
250
+ return {
251
+ segment: ["groups", "group_members", "allowlist"].includes(segment) ? segment : "users",
252
+ users,
253
+ groups,
254
+ groupMembers,
255
+ allowlist,
256
+ labels,
257
+ }
258
+ }
259
+
260
+ async function action({ request }: ActionFunctionArgs) {
261
+ // intent の判定に FormData が要るため、ここでだけ本文を先に読む。
262
+ // 未知 intent は「何も実行しない」ため、ガード前に読んでも権限判断には影響しない。
263
+ const formData = await request.formData()
264
+ const intent = String(formData.get("intent") ?? "")
265
+
266
+ // 未知 intent はストアへ到達しない (ガードも走らせず 400 相当を返す)。
267
+ // Map 参照のためプロトタイプ継承キー ("constructor" 等) を拾わない。
268
+ const access = INTENT_ACCESS.get(intent)
269
+ if (!access) return { ok: false, error: `Unknown intent: ${intent}` }
270
+
271
+ // 操作ごとの CRUD 種別でガードする (削除は delete 権限が要る)
272
+ await config.guards.requireAccess(request, { action: access, intent })
273
+
274
+ try {
275
+ return await dispatch(intent, formData)
276
+ } catch (error) {
277
+ // ガード拒否 (Response/redirect) はそのまま伝播させる。ストア由来の業務エラーだけ
278
+ // {ok:false,error} に写して UI へ届ける (ロックアウト理由・重複パターン等)。
279
+ if (error instanceof Response) throw error
280
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
281
+ }
282
+ }
283
+
284
+ /** intent ごとの実処理。認可・入力検証を通過した後にのみ呼ばれる。 */
285
+ async function dispatch(intent: string, formData: FormData) {
286
+ if (intent === "createUser") {
287
+ await config.store.createUser({ displayName: requireText(formData, "displayName"), email: requireText(formData, "email") })
288
+ return { ok: true }
289
+ }
290
+
291
+ if (intent === "updateUser") {
292
+ await config.store.updateUser(requireId(formData, "userId"), { displayName: requireText(formData, "displayName"), email: requireText(formData, "email") })
293
+ return { ok: true }
294
+ }
295
+
296
+ if (intent === "toggleUserActive") {
297
+ // 破壊側 (無効化) を既定にしない。明示的な "true"/"false" 以外は不正入力として弾く。
298
+ const rawActive = String(formData.get("isActive") ?? "")
299
+ if (rawActive !== "true" && rawActive !== "false") throw new Error('Invalid or missing "isActive"')
300
+ await config.store.toggleUserActive(requireId(formData, "userId"), rawActive === "true")
301
+ return { ok: true }
302
+ }
303
+
304
+ if (intent === "createGroup") {
305
+ await config.store.createGroup({ groupKey: requireText(formData, "groupKey"), name: requireText(formData, "name"), description: optionalText(formData, "description") })
306
+ return { ok: true }
307
+ }
308
+
309
+ if (intent === "updateGroup") {
310
+ await config.store.updateGroup(requireId(formData, "groupId"), { name: requireText(formData, "name"), description: optionalText(formData, "description") })
311
+ return { ok: true }
312
+ }
313
+
314
+ if (intent === "deleteGroup") {
315
+ await config.store.deleteGroup(requireId(formData, "groupId"))
316
+ return { ok: true }
317
+ }
318
+
319
+ if (intent === "addGroupMember") {
320
+ await config.store.addGroupMember(requireId(formData, "groupId"), requireId(formData, "userId"))
321
+ return { ok: true }
322
+ }
323
+
324
+ if (intent === "removeGroupMember") {
325
+ await config.store.removeGroupMember(requireId(formData, "groupId"), requireId(formData, "userId"))
326
+ return { ok: true }
327
+ }
328
+
329
+ if (intent === "addAllowlistEntry") {
330
+ const type: "email" | "domain" = formData.get("type") === "domain" ? "domain" : "email"
331
+ await config.store.addAllowlistEntry({ pattern: requireText(formData, "pattern"), type, description: optionalText(formData, "description") })
332
+ return { ok: true }
333
+ }
334
+
335
+ if (intent === "removeAllowlistEntry") {
336
+ await config.store.removeAllowlistEntry(requireId(formData, "entryId"))
337
+ return { ok: true }
338
+ }
339
+
340
+ // INTENT_ACCESS を通過した intent は上のいずれかで処理済み (到達しない安全網)
341
+ return { ok: false, error: `Unknown intent: ${intent}` }
342
+ }
343
+
344
+ return { loader, action }
345
+ }
@@ -0,0 +1,71 @@
1
+ import type React from "react"
2
+ import { Link, useLoaderData } from "react-router"
3
+ import { defaultUserAdminLabels, type UserAdminLabels } from "./labels"
4
+
5
+ export type UserAdminShellProps = {
6
+ children?: React.ReactNode
7
+ renderHeader?: (props: { title: string; annotation: React.ReactNode }) => React.ReactNode
8
+ }
9
+
10
+ /**
11
+ * Shell layout for user administration pages with header annotation delegation.
12
+ * ヘッダー注釈委譲を備えたユーザー管理ページのシェルレイアウト。
13
+ */
14
+ export function UserAdminShell({ children, renderHeader }: UserAdminShellProps) {
15
+ const data = useLoaderData() as { segment?: string; labels?: UserAdminLabels } | undefined
16
+ const segment = data?.segment ?? "users"
17
+ const labels = data?.labels ?? defaultUserAdminLabels
18
+
19
+ const tabs = [
20
+ { key: "users", label: labels.tabs.users, href: "/users" },
21
+ { key: "groups", label: labels.tabs.groups, href: "/users/groups" },
22
+ { key: "group_members", label: labels.tabs.groupMembers, href: "/users/group_members" },
23
+ { key: "allowlist", label: labels.tabs.allowlist, href: "/users/allowlist" },
24
+ ]
25
+
26
+ const annotation = (
27
+ <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }} data-testid="user-admin-header-annotation">
28
+ <span style={{ fontSize: "0.75rem", backgroundColor: "#e0f2fe", color: "#0369a1", padding: "2px 8px", borderRadius: "12px", fontWeight: 600 }}>{labels.heading}</span>
29
+ </div>
30
+ )
31
+
32
+ return (
33
+ <div data-testid="user-admin-shell" style={{ width: "100%", minHeight: "100vh", backgroundColor: "#f8fafc", padding: "1.5rem", boxSizing: "border-box" }}>
34
+ {renderHeader ? (
35
+ renderHeader({ title: labels.title, annotation })
36
+ ) : (
37
+ <div style={{ marginBottom: "1.5rem", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
38
+ <h1 style={{ margin: 0, fontSize: "1.5rem", fontWeight: 700, color: "#0f172a" }}>{labels.title}</h1>
39
+ {annotation}
40
+ </div>
41
+ )}
42
+
43
+ <div style={{ display: "flex", gap: "0.5rem", marginBottom: "1.2rem", borderBottom: "2px solid #e2e8f0", paddingBottom: "0.5rem" }}>
44
+ {tabs.map((tab) => {
45
+ const isActive = segment === tab.key
46
+ return (
47
+ <Link
48
+ key={tab.key}
49
+ to={tab.href}
50
+ data-testid={`user-admin-tab-${tab.key}`}
51
+ style={{
52
+ padding: "0.5rem 1rem",
53
+ borderRadius: "6px 6px 0 0",
54
+ fontSize: "0.88rem",
55
+ fontWeight: isActive ? 700 : 500,
56
+ color: isActive ? "#0284c7" : "#64748b",
57
+ backgroundColor: isActive ? "#ffffff" : "transparent",
58
+ textDecoration: "none",
59
+ borderBottom: isActive ? "3px solid #0284c7" : "3px solid transparent",
60
+ transition: "all 0.15s ease",
61
+ }}>
62
+ {tab.label}
63
+ </Link>
64
+ )
65
+ })}
66
+ </div>
67
+
68
+ <main>{children}</main>
69
+ </div>
70
+ )
71
+ }