@aiquants/auth-react-router 0.9.1 → 0.11.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/dist/admin.d.mts +70 -5
- package/dist/admin.d.ts +70 -5
- 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/server.d.mts +8 -2
- package/dist/server.d.ts +8 -2
- package/dist/server.js +1 -1
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +1 -1
- package/dist/server.mjs.map +1 -1
- package/package.json +3 -3
- package/src/admin/labels.ts +84 -0
- package/src/admin/server.ts +137 -7
- package/src/admin/views.tsx +119 -29
- package/src/server/google-strategy.ts +1 -1
- package/src/server/ports.ts +8 -2
package/src/admin/server.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AuthAllowlistEntry, AuthGroup, AuthGroupMember, AuthUser } from "@aiquants/auth-core"
|
|
1
|
+
import type { AuthAllowlistEntry, AuthDirectoryMembershipMode, AuthDirectoryStatus, AuthGroup, AuthGroupMember, AuthUser } from "@aiquants/auth-core"
|
|
2
2
|
import { type PartialUserAdminLabels, resolveUserAdminLabels } from "./labels"
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -23,6 +23,38 @@ export type UserAdminStore = {
|
|
|
23
23
|
listAllowlist: () => Promise<AuthAllowlistEntry[]>
|
|
24
24
|
addAllowlistEntry: (data: { pattern: string; type: "email" | "domain"; description?: string }) => Promise<AuthAllowlistEntry>
|
|
25
25
|
removeAllowlistEntry: (id: number) => Promise<void>
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Allows every member of an externally-sourced group to sign in.
|
|
29
|
+
* 外部供給グループのメンバー全員にサインインを許可する処理。
|
|
30
|
+
*
|
|
31
|
+
* 判定は上流メンバー台帳を読むため、ローカル管理のグループを渡しても誰にも何も与えない。
|
|
32
|
+
* ストアはそれを拒否すること (無言の no-op を作らない)。
|
|
33
|
+
*/
|
|
34
|
+
addAllowlistGroup: (groupId: number, description?: string) => Promise<void>
|
|
35
|
+
removeAllowlistGroup: (id: number) => Promise<void>
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Links a local group to a group in the upstream directory.
|
|
39
|
+
* ローカルグループを上流ディレクトリのグループへ結び付ける処理。
|
|
40
|
+
*
|
|
41
|
+
* リンク後、そのグループの名称と所属は上流が所有する。ロール割当はローカルのままである。
|
|
42
|
+
*/
|
|
43
|
+
linkDirectoryGroup: (groupId: number, data: { provider: string; externalKey: string; membershipMode: AuthDirectoryMembershipMode }) => Promise<void>
|
|
44
|
+
/** Stops following the upstream, keeping the memberships already projected. 連携の解除。 */
|
|
45
|
+
unlinkDirectoryGroup: (groupId: number) => Promise<void>
|
|
46
|
+
/** Pauses or resumes synchronization for one group. 1 グループの同期を停止・再開する処理。 */
|
|
47
|
+
setDirectorySyncEnabled: (groupId: number, isSyncEnabled: boolean) => Promise<void>
|
|
48
|
+
/**
|
|
49
|
+
* Queues a synchronization request. 同期要求をキューへ積む処理。
|
|
50
|
+
*
|
|
51
|
+
* 上流へ到達できるのは同期ジョブだけなので、web からは要求を残すことしかできない。
|
|
52
|
+
*
|
|
53
|
+
* @param groupId `null` requests every linked group of the tenant. `null` はテナント全件。
|
|
54
|
+
*/
|
|
55
|
+
requestDirectorySync: (groupId: number | null) => Promise<void>
|
|
56
|
+
/** Health of the synchronization, for the header warning. 同期の健全性 (警告表示用)。 */
|
|
57
|
+
getDirectoryStatus: () => Promise<AuthDirectoryStatus>
|
|
26
58
|
}
|
|
27
59
|
|
|
28
60
|
/**
|
|
@@ -36,16 +68,16 @@ export function createInMemoryUserAdminStore(initial?: { users?: AuthUser[]; gro
|
|
|
36
68
|
{ id: 3, email: "user2@example.com", displayName: "営業 花子", isActive: true },
|
|
37
69
|
]
|
|
38
70
|
let groups: AuthGroup[] = initial?.groups ?? [
|
|
39
|
-
{ id: 1, groupKey: "dev-team", name: "開発チーム", description: "システム開発担当グループ", isActive: true },
|
|
40
|
-
{ id: 2, groupKey: "sales-dept", name: "営業部", description: "営業ソリューション担当", isActive: true },
|
|
71
|
+
{ id: 1, groupKey: "dev-team", name: "開発チーム", description: "システム開発担当グループ", isActive: true, readOnly: false },
|
|
72
|
+
{ id: 2, groupKey: "sales-dept", name: "営業部", description: "営業ソリューション担当", isActive: true, readOnly: false },
|
|
41
73
|
]
|
|
42
74
|
let groupMembers: AuthGroupMember[] = initial?.groupMembers ?? [
|
|
43
75
|
{ id: 1, groupId: 1, userId: 2 },
|
|
44
76
|
{ id: 2, groupId: 2, userId: 3 },
|
|
45
77
|
]
|
|
46
78
|
let allowlist: AuthAllowlistEntry[] = initial?.allowlist ?? [
|
|
47
|
-
{ id: 1, pattern: "admin@example.com", type: "email", description: "初期管理者" },
|
|
48
|
-
{ id: 2, pattern: "@example.com", type: "domain", description: "社内ドメイン" },
|
|
79
|
+
{ id: 1, pattern: "admin@example.com", type: "email", description: "初期管理者", owner: { kind: "tenant", tenantId: "default" }, readOnly: false },
|
|
80
|
+
{ id: 2, pattern: "@example.com", type: "domain", description: "社内ドメイン", owner: { kind: "tenant", tenantId: "default" }, readOnly: false },
|
|
49
81
|
]
|
|
50
82
|
|
|
51
83
|
return {
|
|
@@ -85,6 +117,7 @@ export function createInMemoryUserAdminStore(initial?: { users?: AuthUser[]; gro
|
|
|
85
117
|
name: data.name,
|
|
86
118
|
description: data.description,
|
|
87
119
|
isActive: true,
|
|
120
|
+
readOnly: false,
|
|
88
121
|
}
|
|
89
122
|
groups.push(newGroup)
|
|
90
123
|
return newGroup
|
|
@@ -127,6 +160,8 @@ export function createInMemoryUserAdminStore(initial?: { users?: AuthUser[]; gro
|
|
|
127
160
|
pattern: data.pattern,
|
|
128
161
|
type: data.type,
|
|
129
162
|
description: data.description,
|
|
163
|
+
owner: { kind: "tenant", tenantId: "default" },
|
|
164
|
+
readOnly: false,
|
|
130
165
|
}
|
|
131
166
|
allowlist.push(newEntry)
|
|
132
167
|
return newEntry
|
|
@@ -134,6 +169,33 @@ export function createInMemoryUserAdminStore(initial?: { users?: AuthUser[]; gro
|
|
|
134
169
|
async removeAllowlistEntry(id) {
|
|
135
170
|
allowlist = allowlist.filter((a) => a.id !== id)
|
|
136
171
|
},
|
|
172
|
+
|
|
173
|
+
// --- ディレクトリ連携 ------------------------------------------------------------
|
|
174
|
+
// このメモリ実装はパッケージ自身の spec 用のテストシームであり、上流を持たない。
|
|
175
|
+
// 連携面は「結線されていない配備」を表現する形で応答する
|
|
176
|
+
async addAllowlistGroup(groupId, description) {
|
|
177
|
+
const group = groups.find((g) => g.id === groupId)
|
|
178
|
+
if (!group) throw new Error(`Group not found: ${groupId}`)
|
|
179
|
+
allowlist = [...allowlist, { id: Math.max(...allowlist.map((a) => a.id), 0) + 1, type: "group", groupId, groupKey: group.groupKey, groupName: group.name, description, owner: { kind: "tenant", tenantId: "default" }, readOnly: false }]
|
|
180
|
+
},
|
|
181
|
+
async removeAllowlistGroup(id) {
|
|
182
|
+
allowlist = allowlist.filter((a) => !(a.type === "group" && a.id === id))
|
|
183
|
+
},
|
|
184
|
+
async linkDirectoryGroup() {
|
|
185
|
+
throw new Error("This in-memory store has no upstream directory; inject a real store to link groups.")
|
|
186
|
+
},
|
|
187
|
+
async unlinkDirectoryGroup() {
|
|
188
|
+
throw new Error("This in-memory store has no upstream directory; inject a real store to unlink groups.")
|
|
189
|
+
},
|
|
190
|
+
async setDirectorySyncEnabled() {
|
|
191
|
+
throw new Error("This in-memory store has no upstream directory; inject a real store to pause synchronization.")
|
|
192
|
+
},
|
|
193
|
+
async requestDirectorySync() {
|
|
194
|
+
throw new Error("This in-memory store has no upstream directory; inject a real store to request a sync.")
|
|
195
|
+
},
|
|
196
|
+
async getDirectoryStatus() {
|
|
197
|
+
return { isEnabled: false, linkedGroupCount: 0, isStale: false, pausedGroupCount: 0, stalenessThresholdSec: 0, erroredGroupIds: [], pendingRequestCount: 0 }
|
|
198
|
+
},
|
|
137
199
|
}
|
|
138
200
|
}
|
|
139
201
|
|
|
@@ -188,6 +250,16 @@ const INTENT_ACCESS = new Map<string, UserAdminAccess>([
|
|
|
188
250
|
["removeGroupMember", "delete"],
|
|
189
251
|
["addAllowlistEntry", "create"],
|
|
190
252
|
["removeAllowlistEntry", "delete"],
|
|
253
|
+
["addAllowlistGroup", "create"],
|
|
254
|
+
["removeAllowlistGroup", "delete"],
|
|
255
|
+
// 連携の開始・解除は行の追加削除ではなく、既存グループの属性変更である。create / delete に
|
|
256
|
+
// 寄せると、グループ本体を作れない権限でリンクだけ張れてしまう
|
|
257
|
+
["linkDirectoryGroup", "update"],
|
|
258
|
+
["unlinkDirectoryGroup", "update"],
|
|
259
|
+
["setDirectorySyncEnabled", "update"],
|
|
260
|
+
// 書き込みを伴わないが update を要求する。読取権限だけの主体が上流問い合わせを誘発できると、
|
|
261
|
+
// レート制限を外部から枯渇させられる
|
|
262
|
+
["requestDirectorySync", "update"],
|
|
191
263
|
])
|
|
192
264
|
|
|
193
265
|
/**
|
|
@@ -259,6 +331,33 @@ function requireText(formData: FormData, field: string): string {
|
|
|
259
331
|
return value
|
|
260
332
|
}
|
|
261
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Reads an explicit boolean. 明示的な真偽値の読み取り。
|
|
336
|
+
*
|
|
337
|
+
* `"true"` / `"false"` のみを受け付ける。欠落を既定へ倒すと、破壊側 (停止) が既定方向になる。
|
|
338
|
+
*
|
|
339
|
+
* @throws when the field is absent or not one of the two literals. 明示指定でない場合。
|
|
340
|
+
*/
|
|
341
|
+
function requireBoolean(formData: FormData, field: string): boolean {
|
|
342
|
+
const raw = String(formData.get(field) ?? "")
|
|
343
|
+
if (raw !== "true" && raw !== "false") throw new Error(`Invalid or missing "${field}"`)
|
|
344
|
+
return raw === "true"
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Reads the membership expansion mode. 所属展開モードの読み取り。
|
|
349
|
+
*
|
|
350
|
+
* 既定へ倒さない。入れ子展開が黙って有効になると権限が静かに広がり、黙って無効になると
|
|
351
|
+
* 入れ子側の全員が削除候補になる。どちらの向きにも倒さず、明示させる。
|
|
352
|
+
*
|
|
353
|
+
* @throws when the value is not one of the two modes. 2 値でない場合。
|
|
354
|
+
*/
|
|
355
|
+
function requireMembershipMode(formData: FormData): AuthDirectoryMembershipMode {
|
|
356
|
+
const raw = String(formData.get("membershipMode") ?? "")
|
|
357
|
+
if (raw !== "direct" && raw !== "transitive") throw new Error('Invalid or missing "membershipMode"')
|
|
358
|
+
return raw
|
|
359
|
+
}
|
|
360
|
+
|
|
262
361
|
/** Reads an optional trimmed string; blank becomes `undefined`. 任意文字列の読み取り (空は undefined)。 */
|
|
263
362
|
function optionalText(formData: FormData, field: string): string | undefined {
|
|
264
363
|
const value = String(formData.get(field) ?? "").trim()
|
|
@@ -281,11 +380,12 @@ export function createUserAdminApp(config: UserAdminAppConfig) {
|
|
|
281
380
|
const pathSegments = url.pathname.split("/").filter(Boolean)
|
|
282
381
|
const segment = pathSegments[pathSegments.length - 1] ?? "users"
|
|
283
382
|
|
|
284
|
-
const [users, groups, groupMembers, allowlist, myPermissions] = await Promise.all([
|
|
383
|
+
const [users, groups, groupMembers, allowlist, directoryStatus, myPermissions] = await Promise.all([
|
|
285
384
|
config.store.listUsers(),
|
|
286
385
|
config.store.listGroups(),
|
|
287
386
|
config.store.listGroupMembers(),
|
|
288
387
|
config.store.listAllowlist(),
|
|
388
|
+
config.store.getDirectoryStatus(),
|
|
289
389
|
// 取得できない場合は null (= 権限不明)。空マップに倒すと「付与ゼロ」と区別できなくなる
|
|
290
390
|
config.getMyPermissions
|
|
291
391
|
? config.getMyPermissions(request).catch((e) => {
|
|
@@ -297,11 +397,12 @@ export function createUserAdminApp(config: UserAdminAppConfig) {
|
|
|
297
397
|
|
|
298
398
|
return {
|
|
299
399
|
resourceLabels: config.resourceLabels,
|
|
300
|
-
segment: ["groups", "group_members", "allowlist"].includes(segment) ? segment : "users",
|
|
400
|
+
segment: ["groups", "group_members", "allowlist", "directory"].includes(segment) ? segment : "users",
|
|
301
401
|
users,
|
|
302
402
|
groups,
|
|
303
403
|
groupMembers,
|
|
304
404
|
allowlist,
|
|
405
|
+
directoryStatus,
|
|
305
406
|
myPermissions,
|
|
306
407
|
labels,
|
|
307
408
|
}
|
|
@@ -382,6 +483,35 @@ export function createUserAdminApp(config: UserAdminAppConfig) {
|
|
|
382
483
|
return { ok: true }
|
|
383
484
|
}
|
|
384
485
|
|
|
486
|
+
if (intent === "addAllowlistGroup") {
|
|
487
|
+
await config.store.addAllowlistGroup(requireId(formData, "groupId"), optionalText(formData, "description"))
|
|
488
|
+
return { ok: true }
|
|
489
|
+
}
|
|
490
|
+
if (intent === "removeAllowlistGroup") {
|
|
491
|
+
await config.store.removeAllowlistGroup(requireId(formData, "entryId"))
|
|
492
|
+
return { ok: true }
|
|
493
|
+
}
|
|
494
|
+
if (intent === "linkDirectoryGroup") {
|
|
495
|
+
await config.store.linkDirectoryGroup(requireId(formData, "groupId"), {
|
|
496
|
+
provider: requireText(formData, "provider"),
|
|
497
|
+
externalKey: requireText(formData, "externalKey"),
|
|
498
|
+
membershipMode: requireMembershipMode(formData),
|
|
499
|
+
})
|
|
500
|
+
return { ok: true }
|
|
501
|
+
}
|
|
502
|
+
if (intent === "unlinkDirectoryGroup") {
|
|
503
|
+
await config.store.unlinkDirectoryGroup(requireId(formData, "groupId"))
|
|
504
|
+
return { ok: true }
|
|
505
|
+
}
|
|
506
|
+
if (intent === "setDirectorySyncEnabled") {
|
|
507
|
+
await config.store.setDirectorySyncEnabled(requireId(formData, "groupId"), requireBoolean(formData, "isSyncEnabled"))
|
|
508
|
+
return { ok: true }
|
|
509
|
+
}
|
|
510
|
+
if (intent === "requestDirectorySync") {
|
|
511
|
+
const raw = String(formData.get("groupId") ?? "").trim()
|
|
512
|
+
await config.store.requestDirectorySync(raw === "" ? null : requireId(formData, "groupId"))
|
|
513
|
+
return { ok: true }
|
|
514
|
+
}
|
|
385
515
|
if (intent === "removeAllowlistEntry") {
|
|
386
516
|
await config.store.removeAllowlistEntry(requireId(formData, "entryId"))
|
|
387
517
|
return { ok: true }
|
package/src/admin/views.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AuthAllowlistEntry, AuthGroup, AuthGroupMember, AuthUser } from "@aiquants/auth-core"
|
|
1
|
+
import type { AuthAllowlistEntry, AuthDirectoryStatus, AuthGroup, AuthGroupMember, AuthUser } from "@aiquants/auth-core"
|
|
2
2
|
import type React from "react"
|
|
3
3
|
import { useState } from "react"
|
|
4
4
|
import { useFetcher, useLoaderData } from "react-router"
|
|
@@ -11,6 +11,7 @@ export type UserAdminLoaderData = {
|
|
|
11
11
|
groups: AuthGroup[]
|
|
12
12
|
groupMembers: AuthGroupMember[]
|
|
13
13
|
allowlist: AuthAllowlistEntry[]
|
|
14
|
+
directoryStatus: AuthDirectoryStatus
|
|
14
15
|
labels?: UserAdminLabels
|
|
15
16
|
}
|
|
16
17
|
|
|
@@ -234,6 +235,33 @@ export function UsersView() {
|
|
|
234
235
|
* View for managing user groups (create, edit, delete).
|
|
235
236
|
* ユーザーグループ管理ビュー (作成・編集・削除)。
|
|
236
237
|
*/
|
|
238
|
+
/**
|
|
239
|
+
* Warns when synchronization is not actually keeping the mirror fresh.
|
|
240
|
+
* 同期が実際には鏡を新しく保てていないことを警告する部品。
|
|
241
|
+
*
|
|
242
|
+
* 同期の停止は「上流から外れた人がテナントに居続ける」というセキュリティ事象である。件数と
|
|
243
|
+
* エラー数だけを見ると何も起きていないように読めるため、**滞留・全停止・未結線の 3 つを
|
|
244
|
+
* それぞれ名指しで** 出す。出さなければ、状態を計算している意味がない。
|
|
245
|
+
*/
|
|
246
|
+
function DirectorySyncBanner({ status, labels }: { status: AuthDirectoryStatus; labels: UserAdminLabels }) {
|
|
247
|
+
if (status.linkedGroupCount === 0) return null
|
|
248
|
+
const warnings: Array<{ id: string; text: string }> = []
|
|
249
|
+
if (!status.isEnabled) warnings.push({ id: "disabled", text: labels.directoryView.disabledWarning })
|
|
250
|
+
if (status.pausedGroupCount > 0) warnings.push({ id: "paused", text: `${labels.directoryView.syncPaused}: ${status.pausedGroupCount} / ${status.linkedGroupCount}` })
|
|
251
|
+
if (status.isStale) warnings.push({ id: "stale", text: labels.directoryView.staleWarning })
|
|
252
|
+
if (status.erroredGroupIds.length > 0) warnings.push({ id: "errored", text: `${labels.directoryView.title}: ${status.erroredGroupIds.join(", ")}` })
|
|
253
|
+
if (warnings.length === 0) return null
|
|
254
|
+
return (
|
|
255
|
+
<div data-testid="directory-sync-banner" role="status" style={{ display: "flex", flexDirection: "column", gap: "0.35rem", padding: "0.7rem 1rem", borderRadius: "6px", border: "1px solid #fde68a", backgroundColor: "#fffbeb", color: "#92400e", fontSize: "0.85rem" }}>
|
|
256
|
+
{warnings.map((w) => (
|
|
257
|
+
<span key={w.id} data-directory-warning={w.id}>
|
|
258
|
+
{w.text}
|
|
259
|
+
</span>
|
|
260
|
+
))}
|
|
261
|
+
</div>
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
237
265
|
export function GroupsView() {
|
|
238
266
|
const data = useLoaderData() as UserAdminLoaderData
|
|
239
267
|
const fetcher = useFetcher()
|
|
@@ -274,6 +302,7 @@ export function GroupsView() {
|
|
|
274
302
|
return (
|
|
275
303
|
<section data-testid="groups-view-section" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
|
276
304
|
<ActionError fetcher={fetcher} />
|
|
305
|
+
<DirectorySyncBanner status={data.directoryStatus} labels={labels} />
|
|
277
306
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", backgroundColor: "#ffffff", padding: "1rem", borderRadius: "8px", border: "1px solid #e2e8f0" }}>
|
|
278
307
|
<input
|
|
279
308
|
type="text"
|
|
@@ -292,34 +321,58 @@ export function GroupsView() {
|
|
|
292
321
|
{filteredGroups.map((g) => {
|
|
293
322
|
const memberCount = data.groupMembers.filter((m) => m.groupId === g.id).length
|
|
294
323
|
return (
|
|
295
|
-
<div
|
|
324
|
+
<div
|
|
325
|
+
key={g.id}
|
|
326
|
+
style={{ backgroundColor: "#ffffff", borderRadius: "8px", border: "1px solid #e2e8f0", padding: "1rem", display: "flex", flexDirection: "column", justifyContent: "space-between" }}
|
|
327
|
+
data-testid={`group-card-${g.id}`}
|
|
328
|
+
data-group-readonly={g.readOnly ? "true" : "false"}>
|
|
296
329
|
<div>
|
|
297
330
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "0.5rem" }}>
|
|
298
331
|
<h4 style={{ margin: 0, fontSize: "1rem", color: "#0f172a" }}>{g.name}</h4>
|
|
299
332
|
<span style={{ fontSize: "0.72rem", backgroundColor: "#e0e7ff", color: "#4338ca", padding: "2px 6px", borderRadius: "4px", fontFamily: "monospace" }}>{g.groupKey}</span>
|
|
300
333
|
</div>
|
|
301
334
|
<p style={{ margin: "0 0 0.8rem 0", fontSize: "0.82rem", color: "#64748b", minHeight: "2.4em" }}>{g.description || labels.groupsView.noDescription}</p>
|
|
335
|
+
{/* 所属も名称も上流が所有していることを、操作を試す前に伝える */}
|
|
336
|
+
{g.externalLink && (
|
|
337
|
+
<div data-testid={`group-source-${g.id}`} style={{ display: "flex", alignItems: "center", gap: "6px", flexWrap: "wrap", marginBottom: "0.6rem" }}>
|
|
338
|
+
<span style={{ fontSize: "0.72rem", padding: "2px 8px", borderRadius: "10px", backgroundColor: "#ede9fe", color: "#6d28d9", fontWeight: 600 }}>{labels.directoryView.managedUpstream}</span>
|
|
339
|
+
<span style={{ fontSize: "0.72rem", fontFamily: "monospace", color: "#64748b" }}>{g.externalLink.externalKey}</span>
|
|
340
|
+
{g.externalLink.unresolvedCount > 0 && (
|
|
341
|
+
<span data-testid={`group-unresolved-${g.id}`} style={{ fontSize: "0.72rem", color: "#b45309" }}>
|
|
342
|
+
{g.externalLink.unresolvedCount} {labels.directoryView.unresolved}
|
|
343
|
+
</span>
|
|
344
|
+
)}
|
|
345
|
+
</div>
|
|
346
|
+
)}
|
|
302
347
|
</div>
|
|
303
348
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", borderTop: "1px solid #f1f5f9", paddingTop: "0.6rem" }}>
|
|
304
349
|
<span style={{ fontSize: "0.78rem", color: "#475569" }}>
|
|
305
|
-
👥
|
|
350
|
+
👥 <strong>{memberCount}</strong>
|
|
306
351
|
</span>
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
352
|
+
{/* 上流が所有する行では操作を **描かない**。無効化したボタンを出すと、
|
|
353
|
+
押せる操作と見分けがつかず、押せない理由も伝わらない */}
|
|
354
|
+
{g.readOnly ? (
|
|
355
|
+
<span data-testid={`readonly-group-${g.id}`} style={{ fontSize: "0.75rem", color: "#94a3b8" }}>
|
|
356
|
+
{labels.allowlistView.readOnly}
|
|
357
|
+
</span>
|
|
358
|
+
) : (
|
|
359
|
+
<div>
|
|
360
|
+
<button
|
|
361
|
+
type="button"
|
|
362
|
+
onClick={() => setEditingGroup(g)}
|
|
363
|
+
data-testid={`edit-group-btn-${g.id}`}
|
|
364
|
+
style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #cbd5e1", backgroundColor: "#ffffff", fontSize: "0.75rem", cursor: "pointer", marginRight: "4px" }}>
|
|
365
|
+
{labels.usersView.edit}
|
|
366
|
+
</button>
|
|
367
|
+
<button
|
|
368
|
+
type="button"
|
|
369
|
+
onClick={() => handleDeleteGroup(g.id)}
|
|
370
|
+
data-testid={`delete-group-btn-${g.id}`}
|
|
371
|
+
style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #fee2e2", backgroundColor: "#fff5f5", color: "#b91c1c", fontSize: "0.75rem", cursor: "pointer" }}>
|
|
372
|
+
{labels.allowlistView.delete}
|
|
373
|
+
</button>
|
|
374
|
+
</div>
|
|
375
|
+
)}
|
|
323
376
|
</div>
|
|
324
377
|
</div>
|
|
325
378
|
)
|
|
@@ -541,6 +594,20 @@ export function GroupMembersView() {
|
|
|
541
594
|
)
|
|
542
595
|
}
|
|
543
596
|
|
|
597
|
+
/** Badge colours per allowlist kind. 許可種別ごとのバッジ配色。 */
|
|
598
|
+
function allowlistBadgeStyle(type: AuthAllowlistEntry["type"]): { backgroundColor: string; color: string } {
|
|
599
|
+
if (type === "email") return { backgroundColor: "#e0f2fe", color: "#0369a1" }
|
|
600
|
+
if (type === "domain") return { backgroundColor: "#fef3c7", color: "#b45309" }
|
|
601
|
+
return { backgroundColor: "#ede9fe", color: "#6d28d9" }
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Badge text per allowlist kind. 許可種別ごとのバッジ文言。 */
|
|
605
|
+
function allowlistTypeLabel(type: AuthAllowlistEntry["type"], labels: UserAdminLabels): string {
|
|
606
|
+
if (type === "email") return labels.allowlistView.emailTypeShort
|
|
607
|
+
if (type === "domain") return labels.allowlistView.domainTypeShort
|
|
608
|
+
return labels.allowlistView.groupTypeShort
|
|
609
|
+
}
|
|
610
|
+
|
|
544
611
|
/**
|
|
545
612
|
* View for managing authentication allowlist entries.
|
|
546
613
|
* アクセス許可リスト (Allowlist) 管理ビュー。
|
|
@@ -566,6 +633,11 @@ export function AllowlistView() {
|
|
|
566
633
|
fetcher.submit({ intent: "removeAllowlistEntry", entryId: String(id) }, { method: "post" })
|
|
567
634
|
}
|
|
568
635
|
|
|
636
|
+
/** グループ許可は別の表に住むため、別の intent で消す。 */
|
|
637
|
+
const handleRemoveGroupEntry = (id: number) => {
|
|
638
|
+
fetcher.submit({ intent: "removeAllowlistGroup", entryId: String(id) }, { method: "post" })
|
|
639
|
+
}
|
|
640
|
+
|
|
569
641
|
return (
|
|
570
642
|
<section data-testid="allowlist-view-section" style={{ display: "flex", flexDirection: "column", gap: "1.2rem" }}>
|
|
571
643
|
<ActionError fetcher={fetcher} />
|
|
@@ -621,7 +693,7 @@ export function AllowlistView() {
|
|
|
621
693
|
<thead>
|
|
622
694
|
<tr style={{ backgroundColor: "#f8fafc", borderBottom: "1px solid #e2e8f0", color: "#475569" }}>
|
|
623
695
|
<th style={{ padding: "10px 14px" }}>ID</th>
|
|
624
|
-
<th style={{ padding: "10px 14px" }}>{labels.allowlistView.
|
|
696
|
+
<th style={{ padding: "10px 14px" }}>{labels.allowlistView.target}</th>
|
|
625
697
|
<th style={{ padding: "10px 14px" }}>{labels.allowlistView.type}</th>
|
|
626
698
|
<th style={{ padding: "10px 14px" }}>{labels.allowlistView.description}</th>
|
|
627
699
|
<th style={{ padding: "10px 14px", textAlign: "right" }}>{labels.allowlistView.actions}</th>
|
|
@@ -629,17 +701,35 @@ export function AllowlistView() {
|
|
|
629
701
|
</thead>
|
|
630
702
|
<tbody>
|
|
631
703
|
{data.allowlist.map((item) => (
|
|
632
|
-
<tr key={item.id} style={{ borderBottom: "1px solid #f1f5f9" }} data-testid={`allowlist-row-${item.id}`}>
|
|
704
|
+
<tr key={`${item.type}-${item.id}`} style={{ borderBottom: "1px solid #f1f5f9" }} data-testid={`allowlist-row-${item.id}`} data-allowlist-type={item.type}>
|
|
633
705
|
<td style={{ padding: "10px 14px", color: "#64748b" }}>#{item.id}</td>
|
|
634
|
-
<td style={{ padding: "10px 14px", fontWeight: 600, color: "#0f172a"
|
|
706
|
+
<td style={{ padding: "10px 14px", fontWeight: 600, color: "#0f172a" }}>
|
|
707
|
+
{/* パターン許可とグループ許可では正準値が違う。グループはアドレスではなく
|
|
708
|
+
グループそのものが対象なので、名前とキーを見せる */}
|
|
709
|
+
{item.type === "group" ? (
|
|
710
|
+
<span data-testid={`allowlist-group-${item.id}`}>
|
|
711
|
+
{item.groupName}
|
|
712
|
+
<span style={{ marginLeft: "6px", fontFamily: "monospace", fontSize: "0.78rem", color: "#64748b" }}>{item.groupKey}</span>
|
|
713
|
+
</span>
|
|
714
|
+
) : (
|
|
715
|
+
<span style={{ fontFamily: "monospace" }}>{item.pattern}</span>
|
|
716
|
+
)}
|
|
717
|
+
</td>
|
|
635
718
|
<td style={{ padding: "10px 14px" }}>
|
|
636
|
-
<span style={{ fontSize: "0.75rem", padding: "2px 8px", borderRadius: "10px",
|
|
637
|
-
|
|
638
|
-
|
|
719
|
+
<span style={{ fontSize: "0.75rem", padding: "2px 8px", borderRadius: "10px", ...allowlistBadgeStyle(item.type), fontWeight: 600 }}>{allowlistTypeLabel(item.type, labels)}</span>
|
|
720
|
+
</td>
|
|
721
|
+
<td style={{ padding: "10px 14px", color: "#475569" }}>
|
|
722
|
+
{item.description || "-"}
|
|
723
|
+
{/* 配備全体が所有する行は、どのテナントの管理者でも編集できない */}
|
|
724
|
+
{item.owner.kind === "deployment" && (
|
|
725
|
+
<span data-testid={`allowlist-owner-${item.id}`} style={{ marginLeft: "8px", fontSize: "0.72rem", color: "#94a3b8" }}>
|
|
726
|
+
{labels.allowlistView.deploymentOwner}
|
|
727
|
+
</span>
|
|
728
|
+
)}
|
|
639
729
|
</td>
|
|
640
|
-
<td style={{ padding: "10px 14px", color: "#475569" }}>{item.description || "-"}</td>
|
|
641
730
|
<td style={{ padding: "10px 14px", textAlign: "right" }}>
|
|
642
|
-
{/* 変更不能な供給元 (環境変数など)
|
|
731
|
+
{/* 変更不能な供給元 (環境変数など) は削除ボタンを出さない。無効化ではなく
|
|
732
|
+
非描画にするのは、押せないボタンが押せる操作と見分けられないため */}
|
|
643
733
|
{item.readOnly ? (
|
|
644
734
|
<span data-testid={`readonly-allowlist-${item.id}`} style={{ fontSize: "0.75rem", color: "#94a3b8" }}>
|
|
645
735
|
{labels.allowlistView.readOnly}
|
|
@@ -647,10 +737,10 @@ export function AllowlistView() {
|
|
|
647
737
|
) : (
|
|
648
738
|
<button
|
|
649
739
|
type="button"
|
|
650
|
-
onClick={() => handleRemoveEntry(item.id)}
|
|
740
|
+
onClick={() => (item.type === "group" ? handleRemoveGroupEntry(item.id) : handleRemoveEntry(item.id))}
|
|
651
741
|
data-testid={`remove-allowlist-btn-${item.id}`}
|
|
652
742
|
style={{ padding: "3px 8px", borderRadius: "4px", border: "1px solid #fee2e2", backgroundColor: "#fff5f5", color: "#b91c1c", fontSize: "0.78rem", cursor: "pointer" }}>
|
|
653
|
-
|
|
743
|
+
{labels.allowlistView.delete}
|
|
654
744
|
</button>
|
|
655
745
|
)}
|
|
656
746
|
</td>
|
|
@@ -120,7 +120,7 @@ export const createGoogleAuthenticator = (config: AuthServerConfig): Authenticat
|
|
|
120
120
|
|
|
121
121
|
// バックエンドでのサインアップ処理 (毎ログイン実行の挙動を保存)
|
|
122
122
|
if (config.backendAuth) {
|
|
123
|
-
await config.backendAuth.signup(provider, accessToken)
|
|
123
|
+
await config.backendAuth.signup(provider, accessToken, profile)
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
logger.info("Authenticated complete: ", profile.displayName)
|
package/src/server/ports.ts
CHANGED
|
@@ -37,8 +37,14 @@ export type SessionConfig = {
|
|
|
37
37
|
export type BackendAuthPort = {
|
|
38
38
|
/** GET /openid/verify 相当。ok なら true。 */
|
|
39
39
|
verify: (provider: string, accessToken: string) => Promise<boolean>
|
|
40
|
-
/**
|
|
41
|
-
|
|
40
|
+
/**
|
|
41
|
+
* POST /openid/signup?overwrite_existing=true 相当 (毎ログイン実行)。
|
|
42
|
+
*
|
|
43
|
+
* `profile` を渡すのは、サインアップ直後にホスト側が identity を必要とするためである。
|
|
44
|
+
* この呼び出しでユーザー行が作られるので、直後に「この人物は何に属するか」を解決したい
|
|
45
|
+
* ホストは、ここでプロフィールを受け取れないとトークンから引き直すしかない。
|
|
46
|
+
*/
|
|
47
|
+
signup: (provider: string, accessToken: string, profile: GoogleProfileBase) => Promise<unknown>
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
/** ユーザー写真解決ポート (DB 参照等の実装はアプリ側)。キャッシュ + dedup はパッケージ内。 */
|