@aiquants/auth-react-router 0.9.1 → 0.12.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,4 +1,5 @@
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
+ import { data } from "react-router"
2
3
  import { type PartialUserAdminLabels, resolveUserAdminLabels } from "./labels"
3
4
 
4
5
  /**
@@ -23,6 +24,38 @@ export type UserAdminStore = {
23
24
  listAllowlist: () => Promise<AuthAllowlistEntry[]>
24
25
  addAllowlistEntry: (data: { pattern: string; type: "email" | "domain"; description?: string }) => Promise<AuthAllowlistEntry>
25
26
  removeAllowlistEntry: (id: number) => Promise<void>
27
+
28
+ /**
29
+ * Allows every member of an externally-sourced group to sign in.
30
+ * 外部供給グループのメンバー全員にサインインを許可する処理。
31
+ *
32
+ * 判定は上流メンバー台帳を読むため、ローカル管理のグループを渡しても誰にも何も与えない。
33
+ * ストアはそれを拒否すること (無言の no-op を作らない)。
34
+ */
35
+ addAllowlistGroup: (groupId: number, description?: string) => Promise<void>
36
+ removeAllowlistGroup: (id: number) => Promise<void>
37
+
38
+ /**
39
+ * Links a local group to a group in the upstream directory.
40
+ * ローカルグループを上流ディレクトリのグループへ結び付ける処理。
41
+ *
42
+ * リンク後、そのグループの名称と所属は上流が所有する。ロール割当はローカルのままである。
43
+ */
44
+ linkDirectoryGroup: (groupId: number, data: { provider: string; externalKey: string; membershipMode: AuthDirectoryMembershipMode }) => Promise<void>
45
+ /** Stops following the upstream, keeping the memberships already projected. 連携の解除。 */
46
+ unlinkDirectoryGroup: (groupId: number) => Promise<void>
47
+ /** Pauses or resumes synchronization for one group. 1 グループの同期を停止・再開する処理。 */
48
+ setDirectorySyncEnabled: (groupId: number, isSyncEnabled: boolean) => Promise<void>
49
+ /**
50
+ * Queues a synchronization request. 同期要求をキューへ積む処理。
51
+ *
52
+ * 上流へ到達できるのは同期ジョブだけなので、web からは要求を残すことしかできない。
53
+ *
54
+ * @param groupId `null` requests every linked group of the tenant. `null` はテナント全件。
55
+ */
56
+ requestDirectorySync: (groupId: number | null) => Promise<void>
57
+ /** Health of the synchronization, for the header warning. 同期の健全性 (警告表示用)。 */
58
+ getDirectoryStatus: () => Promise<AuthDirectoryStatus>
26
59
  }
27
60
 
28
61
  /**
@@ -36,16 +69,16 @@ export function createInMemoryUserAdminStore(initial?: { users?: AuthUser[]; gro
36
69
  { id: 3, email: "user2@example.com", displayName: "営業 花子", isActive: true },
37
70
  ]
38
71
  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 },
72
+ { id: 1, groupKey: "dev-team", name: "開発チーム", description: "システム開発担当グループ", isActive: true, readOnly: false },
73
+ { id: 2, groupKey: "sales-dept", name: "営業部", description: "営業ソリューション担当", isActive: true, readOnly: false },
41
74
  ]
42
75
  let groupMembers: AuthGroupMember[] = initial?.groupMembers ?? [
43
76
  { id: 1, groupId: 1, userId: 2 },
44
77
  { id: 2, groupId: 2, userId: 3 },
45
78
  ]
46
79
  let allowlist: AuthAllowlistEntry[] = initial?.allowlist ?? [
47
- { id: 1, pattern: "admin@example.com", type: "email", description: "初期管理者" },
48
- { id: 2, pattern: "@example.com", type: "domain", description: "社内ドメイン" },
80
+ { id: 1, pattern: "admin@example.com", type: "email", description: "初期管理者", owner: { kind: "tenant", tenantId: "default" }, readOnly: false },
81
+ { id: 2, pattern: "@example.com", type: "domain", description: "社内ドメイン", owner: { kind: "tenant", tenantId: "default" }, readOnly: false },
49
82
  ]
50
83
 
51
84
  return {
@@ -85,6 +118,7 @@ export function createInMemoryUserAdminStore(initial?: { users?: AuthUser[]; gro
85
118
  name: data.name,
86
119
  description: data.description,
87
120
  isActive: true,
121
+ readOnly: false,
88
122
  }
89
123
  groups.push(newGroup)
90
124
  return newGroup
@@ -127,6 +161,8 @@ export function createInMemoryUserAdminStore(initial?: { users?: AuthUser[]; gro
127
161
  pattern: data.pattern,
128
162
  type: data.type,
129
163
  description: data.description,
164
+ owner: { kind: "tenant", tenantId: "default" },
165
+ readOnly: false,
130
166
  }
131
167
  allowlist.push(newEntry)
132
168
  return newEntry
@@ -134,9 +170,52 @@ export function createInMemoryUserAdminStore(initial?: { users?: AuthUser[]; gro
134
170
  async removeAllowlistEntry(id) {
135
171
  allowlist = allowlist.filter((a) => a.id !== id)
136
172
  },
173
+
174
+ // --- ディレクトリ連携 ------------------------------------------------------------
175
+ // このメモリ実装はパッケージ自身の spec 用のテストシームであり、上流を持たない。
176
+ // 連携面は「結線されていない配備」を表現する形で応答する
177
+ async addAllowlistGroup(groupId, description) {
178
+ const group = groups.find((g) => g.id === groupId)
179
+ if (!group) throw new Error(`Group not found: ${groupId}`)
180
+ 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 }]
181
+ },
182
+ async removeAllowlistGroup(id) {
183
+ allowlist = allowlist.filter((a) => !(a.type === "group" && a.id === id))
184
+ },
185
+ async linkDirectoryGroup() {
186
+ throw new Error("This in-memory store has no upstream directory; inject a real store to link groups.")
187
+ },
188
+ async unlinkDirectoryGroup() {
189
+ throw new Error("This in-memory store has no upstream directory; inject a real store to unlink groups.")
190
+ },
191
+ async setDirectorySyncEnabled() {
192
+ throw new Error("This in-memory store has no upstream directory; inject a real store to pause synchronization.")
193
+ },
194
+ async requestDirectorySync() {
195
+ throw new Error("This in-memory store has no upstream directory; inject a real store to request a sync.")
196
+ },
197
+ async getDirectoryStatus() {
198
+ return { isEnabled: false, provider: "", linkedGroupCount: 0, isStale: false, pausedGroupCount: 0, stalenessThresholdSec: 0, erroredGroupIds: [], pendingRequestCount: 0 }
199
+ },
137
200
  }
138
201
  }
139
202
 
203
+ /**
204
+ * Tells a per-operation authorization denial apart from any other failure, without importing an authz library.
205
+ * 認可ライブラリを import せずに、操作単位の認可拒否を他の失敗と見分ける判定。
206
+ *
207
+ * 特定の実装の例外クラスへ `instanceof` を張ると、そのライブラリに縛られる (`AGENTS.md` の
208
+ * クロスパッケージ・ハードコード禁止)。`status === 403` は HTTP の共通語彙なので、これだけを見る。
209
+ *
210
+ * @param error The thrown value. 送出された値。
211
+ * @returns True when it carries HTTP 403. HTTP 403 を名乗る場合に true。
212
+ */
213
+ function isForbidden(error: unknown): boolean {
214
+ // Response でも素の例外でも `status` は共通語彙である。特定の実装の例外クラスへ
215
+ // `instanceof` を張ると、そのライブラリに縛られる
216
+ return typeof error === "object" && error !== null && (error as { status?: unknown }).status === 403
217
+ }
218
+
140
219
  /**
141
220
  * The CRUD verb an operation performs. Deliberately a plain string union so this package stays
142
221
  * independent of any particular authorization library.
@@ -168,6 +247,13 @@ export type UserAdminGuards = {
168
247
  * 述語のつもりで `false` を返す実装を書くと素通りするため、返り値型で明示的に禁じる。
169
248
  */
170
249
  requireAccess: (request: Request, context: { action: UserAdminAccess; intent: string }) => Promise<void> | void
250
+ /**
251
+ * Observes a denial that was converted into an in-page error. 画面内エラーへ変換した拒否の観測点。
252
+ *
253
+ * 拒否を画面内に返すと、既定のエラー経路が拾っていたログが消える。管理面への総当たりが
254
+ * 200 番台の列に紛れて見えなくなるため、宿主が記録できる口を開けておく。
255
+ */
256
+ onDenied?: (context: { intent: string; actions: readonly UserAdminAccess[]; error: unknown }) => void
171
257
  }
172
258
 
173
259
  /**
@@ -177,19 +263,31 @@ export type UserAdminGuards = {
177
263
  * 割当の付与・解除は行の追加・削除であるため、認可管理 UI (`/authz`) の
178
264
  * `assignUserRole` = create / `removeUserRole` = delete と同じ粒度に揃える。
179
265
  */
180
- const INTENT_ACCESS = new Map<string, UserAdminAccess>([
181
- ["createUser", "create"],
182
- ["updateUser", "update"],
183
- ["toggleUserActive", "update"],
184
- ["createGroup", "create"],
185
- ["updateGroup", "update"],
186
- ["deleteGroup", "delete"],
187
- ["addGroupMember", "create"],
188
- ["removeGroupMember", "delete"],
189
- ["addAllowlistEntry", "create"],
190
- ["removeAllowlistEntry", "delete"],
266
+ const INTENT_ACCESS = new Map<string, readonly UserAdminAccess[]>([
267
+ ["createUser", ["create"]],
268
+ ["updateUser", ["update"]],
269
+ ["toggleUserActive", ["update"]],
270
+ ["createGroup", ["create"]],
271
+ ["updateGroup", ["update"]],
272
+ ["deleteGroup", ["delete"]],
273
+ ["addGroupMember", ["create"]],
274
+ ["removeGroupMember", ["delete"]],
275
+ ["addAllowlistEntry", ["create"]],
276
+ ["removeAllowlistEntry", ["delete"]],
277
+ ["addAllowlistGroup", ["create"]],
278
+ ["removeAllowlistGroup", ["delete"]],
279
+ // 連携の開始は、以後の所属の **追加と削除の両方** を上流へ委ねる行為である。所属を足せる
280
+ // だけの主体が「足すことも消すこともできる仕掛け」を据えられてはならない。属性変更でもある
281
+ // ため update も要る
282
+ ["linkDirectoryGroup", ["update", "create", "delete"]],
283
+ // 解除は台帳とグループ単位のサインイン許可を **消す**。属性を戻すだけではないので、
284
+ // 削除権限を持たない主体には許さない
285
+ ["unlinkDirectoryGroup", ["update", "delete"]],
286
+ ["setDirectorySyncEnabled", ["update"]],
287
+ // 書き込みを伴わないが update を要求する。読取権限だけの主体が上流問い合わせを誘発できると、
288
+ // レート制限を外部から枯渇させられる
289
+ ["requestDirectorySync", ["update"]],
191
290
  ])
192
-
193
291
  /**
194
292
  * Neutral per-resource permission summary the admin shell renders in its header badge.
195
293
  * 管理シェルのヘッダーバッジが描画する、リソース単位の中立な権限サマリ。
@@ -259,6 +357,33 @@ function requireText(formData: FormData, field: string): string {
259
357
  return value
260
358
  }
261
359
 
360
+ /**
361
+ * Reads an explicit boolean. 明示的な真偽値の読み取り。
362
+ *
363
+ * `"true"` / `"false"` のみを受け付ける。欠落を既定へ倒すと、破壊側 (停止) が既定方向になる。
364
+ *
365
+ * @throws when the field is absent or not one of the two literals. 明示指定でない場合。
366
+ */
367
+ function requireBoolean(formData: FormData, field: string): boolean {
368
+ const raw = String(formData.get(field) ?? "")
369
+ if (raw !== "true" && raw !== "false") throw new Error(`Invalid or missing "${field}"`)
370
+ return raw === "true"
371
+ }
372
+
373
+ /**
374
+ * Reads the membership expansion mode. 所属展開モードの読み取り。
375
+ *
376
+ * 既定へ倒さない。入れ子展開が黙って有効になると権限が静かに広がり、黙って無効になると
377
+ * 入れ子側の全員が削除候補になる。どちらの向きにも倒さず、明示させる。
378
+ *
379
+ * @throws when the value is not one of the two modes. 2 値でない場合。
380
+ */
381
+ function requireMembershipMode(formData: FormData): AuthDirectoryMembershipMode {
382
+ const raw = String(formData.get("membershipMode") ?? "")
383
+ if (raw !== "direct" && raw !== "transitive") throw new Error('Invalid or missing "membershipMode"')
384
+ return raw
385
+ }
386
+
262
387
  /** Reads an optional trimmed string; blank becomes `undefined`. 任意文字列の読み取り (空は undefined)。 */
263
388
  function optionalText(formData: FormData, field: string): string | undefined {
264
389
  const value = String(formData.get(field) ?? "").trim()
@@ -281,11 +406,12 @@ export function createUserAdminApp(config: UserAdminAppConfig) {
281
406
  const pathSegments = url.pathname.split("/").filter(Boolean)
282
407
  const segment = pathSegments[pathSegments.length - 1] ?? "users"
283
408
 
284
- const [users, groups, groupMembers, allowlist, myPermissions] = await Promise.all([
409
+ const [users, groups, groupMembers, allowlist, directoryStatus, myPermissions] = await Promise.all([
285
410
  config.store.listUsers(),
286
411
  config.store.listGroups(),
287
412
  config.store.listGroupMembers(),
288
413
  config.store.listAllowlist(),
414
+ config.store.getDirectoryStatus(),
289
415
  // 取得できない場合は null (= 権限不明)。空マップに倒すと「付与ゼロ」と区別できなくなる
290
416
  config.getMyPermissions
291
417
  ? config.getMyPermissions(request).catch((e) => {
@@ -297,11 +423,12 @@ export function createUserAdminApp(config: UserAdminAppConfig) {
297
423
 
298
424
  return {
299
425
  resourceLabels: config.resourceLabels,
300
- segment: ["groups", "group_members", "allowlist"].includes(segment) ? segment : "users",
426
+ segment: ["groups", "group_members", "allowlist", "directory"].includes(segment) ? segment : "users",
301
427
  users,
302
428
  groups,
303
429
  groupMembers,
304
430
  allowlist,
431
+ directoryStatus,
305
432
  myPermissions,
306
433
  labels,
307
434
  }
@@ -318,8 +445,18 @@ export function createUserAdminApp(config: UserAdminAppConfig) {
318
445
  const access = INTENT_ACCESS.get(intent)
319
446
  if (!access) return { ok: false, error: `Unknown intent: ${intent}` }
320
447
 
321
- // 操作ごとの CRUD 種別でガードする (削除は delete 権限が要る)
322
- await config.guards.requireAccess(request, { action: access, intent })
448
+ // 操作ごとの CRUD 種別でガードする。複数を要求する intent は **すべて** 通ること
449
+ try {
450
+ for (const action of access) await config.guards.requireAccess(request, { action, intent })
451
+ } catch (error) {
452
+ // 403 は「この操作だけができない」であり、画面ごと落とすと **他の操作まで巻き添え** に
453
+ // なる (閲覧と作成はできるのに削除だけ拒否された、で一覧が消える)。401 や再認証の
454
+ // リダイレクト、それ以外の障害はそのまま伝播させる — 握り潰すとログインし直せない
455
+ if (!isForbidden(error)) throw error
456
+ // 監視から消えないよう、状態は 403 のまま返す。画面は data() なので生き残る
457
+ config.guards.onDenied?.({ intent, actions: access, error })
458
+ return data({ ok: false, error: labels.errors.forbidden }, { status: 403 })
459
+ }
323
460
 
324
461
  try {
325
462
  return await dispatch(intent, formData)
@@ -382,6 +519,35 @@ export function createUserAdminApp(config: UserAdminAppConfig) {
382
519
  return { ok: true }
383
520
  }
384
521
 
522
+ if (intent === "addAllowlistGroup") {
523
+ await config.store.addAllowlistGroup(requireId(formData, "groupId"), optionalText(formData, "description"))
524
+ return { ok: true }
525
+ }
526
+ if (intent === "removeAllowlistGroup") {
527
+ await config.store.removeAllowlistGroup(requireId(formData, "entryId"))
528
+ return { ok: true }
529
+ }
530
+ if (intent === "linkDirectoryGroup") {
531
+ await config.store.linkDirectoryGroup(requireId(formData, "groupId"), {
532
+ provider: requireText(formData, "provider"),
533
+ externalKey: requireText(formData, "externalKey"),
534
+ membershipMode: requireMembershipMode(formData),
535
+ })
536
+ return { ok: true }
537
+ }
538
+ if (intent === "unlinkDirectoryGroup") {
539
+ await config.store.unlinkDirectoryGroup(requireId(formData, "groupId"))
540
+ return { ok: true }
541
+ }
542
+ if (intent === "setDirectorySyncEnabled") {
543
+ await config.store.setDirectorySyncEnabled(requireId(formData, "groupId"), requireBoolean(formData, "isSyncEnabled"))
544
+ return { ok: true }
545
+ }
546
+ if (intent === "requestDirectorySync") {
547
+ const raw = String(formData.get("groupId") ?? "").trim()
548
+ await config.store.requestDirectorySync(raw === "" ? null : requireId(formData, "groupId"))
549
+ return { ok: true }
550
+ }
385
551
  if (intent === "removeAllowlistEntry") {
386
552
  await config.store.removeAllowlistEntry(requireId(formData, "entryId"))
387
553
  return { ok: true }
package/src/admin/ui.tsx CHANGED
@@ -1,5 +1,7 @@
1
+ import type { AuthDirectoryStatus, AuthGroup } from "@aiquants/auth-core"
1
2
  import type React from "react"
2
3
  import { Link, useLoaderData } from "react-router"
4
+ import { fill, formatDateTime } from "./format"
3
5
  import { defaultUserAdminLabels, type UserAdminLabels } from "./labels"
4
6
  import type { UserAdminPermission } from "./server"
5
7
 
@@ -34,12 +36,64 @@ function GroupPill({ label, summary }: { label: string; summary: { text: string;
34
36
  )
35
37
  }
36
38
 
39
+ /**
40
+ * Warns when synchronization is not actually keeping the mirror fresh.
41
+ * 同期が実際には鏡を新しく保てていないことを警告する部品。
42
+ *
43
+ * 同期の停止は「上流から外れた人がテナントに居続ける」というセキュリティ事象である。件数と
44
+ * エラー数だけを見ると何も起きていないように読めるため、**滞留・全停止・未結線の 3 つを
45
+ * それぞれ名指しで** 出す。出さなければ、状態を計算している意味がない。
46
+ *
47
+ * どのタブに居ても見えるようにシェル側で描く。ディレクトリの停止はグループ一覧を見ている
48
+ * ときだけ起きる事象ではない。
49
+ */
50
+ function DirectorySyncBanner({ status, labels, groups }: { status?: AuthDirectoryStatus; labels: UserAdminLabels; groups: AuthGroup[] }) {
51
+ if (!status || status.linkedGroupCount === 0) return null
52
+ const L = labels.directoryView
53
+ const warnings: Array<{ id: string; text: string }> = []
54
+ if (!status.isEnabled) warnings.push({ id: "disabled", text: L.disabledWarning })
55
+ if (status.pausedGroupCount > 0) warnings.push({ id: "paused", text: fill(L.pausedTemplate, { paused: status.pausedGroupCount, total: status.linkedGroupCount }) })
56
+ if (status.isStale) {
57
+ const at = status.oldestSyncedAt ? formatDateTime(status.oldestSyncedAt, labels) : L.neverSynced
58
+ warnings.push({ id: "stale", text: `${L.staleWarning} ${fill(L.oldestSyncedTemplate, { at })}` })
59
+ }
60
+ if (status.erroredGroupIds.length > 0) {
61
+ // ID を並べても運用者には何のグループか判らない。名前と、あれば実際の失敗理由を出す
62
+ const described = status.erroredGroupIds.map((id) => {
63
+ const group = groups.find((g) => g.id === id)
64
+ if (!group) return fill(labels.common.idTemplate, { id })
65
+ const reason = group.externalLink?.syncError
66
+ return reason ? fill(L.erroredItemTemplate, { group: group.name, reason }) : group.name
67
+ })
68
+ warnings.push({ id: "errored", text: fill(L.erroredTemplate, { groups: described.join(L.listSeparator) }) })
69
+ }
70
+ if (status.pendingRequestCount > 0) warnings.push({ id: "pending", text: fill(L.pendingTemplate, { count: status.pendingRequestCount }) })
71
+ if (warnings.length === 0) return null
72
+ return (
73
+ // ライブリージョンにはしない。初期描画から在る内容は読み上げられず、見出しも読み順も
74
+ // 失われる。**読める本文** として置き、見出しから辿れるようにする
75
+ <section
76
+ aria-labelledby="directory-sync-heading"
77
+ data-testid="directory-sync-banner"
78
+ style={{ display: "flex", flexDirection: "column", gap: "0.35rem", padding: "0.7rem 1rem", marginBottom: "1rem", borderRadius: "6px", border: "1px solid #fde68a", backgroundColor: "#fffbeb", color: "#92400e", fontSize: "0.85rem" }}>
79
+ <h2 id="directory-sync-heading" style={{ margin: 0, fontSize: "0.9rem", fontWeight: 700 }}>
80
+ {L.title}
81
+ </h2>
82
+ {warnings.map((w) => (
83
+ <span key={w.id} data-directory-warning={w.id}>
84
+ {w.text}
85
+ </span>
86
+ ))}
87
+ </section>
88
+ )
89
+ }
90
+
37
91
  /**
38
92
  * Shell layout for user administration pages with header annotation delegation.
39
93
  * ヘッダー注釈委譲を備えたユーザー管理ページのシェルレイアウト。
40
94
  */
41
95
  export function UserAdminShell({ children, renderHeader }: UserAdminShellProps) {
42
- const data = useLoaderData() as { segment?: string; labels?: UserAdminLabels; myPermissions?: Record<string, UserAdminPermission | undefined> | null; resourceLabels?: Record<string, string> } | undefined
96
+ const data = useLoaderData() as { segment?: string; labels?: UserAdminLabels; myPermissions?: Record<string, UserAdminPermission | undefined> | null; resourceLabels?: Record<string, string>; directoryStatus?: AuthDirectoryStatus; groups?: AuthGroup[] } | undefined
43
97
  const segment = data?.segment ?? "users"
44
98
  const labels = data?.labels ?? defaultUserAdminLabels
45
99
  // null = 未結線または取得失敗。空 {} も「分からない」扱いで、いずれも許可として描画しない
@@ -54,6 +108,9 @@ export function UserAdminShell({ children, renderHeader }: UserAdminShellProps)
54
108
  { key: "group_members", label: labels.tabs.groupMembers, href: "/users/group_members" },
55
109
  { key: "allowlist", label: labels.tabs.allowlist, href: "/users/allowlist" },
56
110
  ]
111
+ // 未結線でも **連携が残っていれば** 出す。停止を警告した直後に、解除や再開ができる唯一の
112
+ // 面だけが消えるのは最悪の挙動である。連携も結線も無い配備でだけ隠す
113
+ if (data?.directoryStatus && (data.directoryStatus.isEnabled || data.directoryStatus.linkedGroupCount > 0)) tabs.push({ key: "directory", label: labels.tabs.directory, href: "/users/directory" })
57
114
 
58
115
  const permEntries = Object.entries(myPermissions ?? {})
59
116
 
@@ -66,21 +123,21 @@ export function UserAdminShell({ children, renderHeader }: UserAdminShellProps)
66
123
  */
67
124
  const getSummary = (perm: UserAdminPermission | undefined) => {
68
125
  if (perm?.canWrite) {
69
- return { text: "編集可", color: "#047857", bgColor: "#ecfdf5", borderColor: "#a7f3d0" }
126
+ return { text: labels.permissions.canWrite, color: "#047857", bgColor: "#ecfdf5", borderColor: "#a7f3d0" }
70
127
  }
71
128
  if (perm?.canRead) {
72
- return { text: "閲覧のみ", color: "#1d4ed8", bgColor: "#eff6ff", borderColor: "#bfdbfe" }
129
+ return { text: labels.permissions.canRead, color: "#1d4ed8", bgColor: "#eff6ff", borderColor: "#bfdbfe" }
73
130
  }
74
- return { text: "権限なし", color: "#64748b", bgColor: "#f1f5f9", borderColor: "#cbd5e1" }
131
+ return { text: labels.permissions.none, color: "#475569", bgColor: "#f1f5f9", borderColor: "#cbd5e1" }
75
132
  }
76
- const unavailableSummary = { text: "権限不明", color: "#92400e", bgColor: "#fffbeb", borderColor: "#fde68a" }
133
+ const unavailableSummary = { text: labels.permissions.unknown, color: "#92400e", bgColor: "#fffbeb", borderColor: "#fde68a" }
77
134
 
78
135
  const annotation = (
79
136
  <div style={{ display: "flex", alignItems: "center", gap: "0.4rem" }} data-testid="user-admin-header-annotation">
80
137
  <span style={{ fontSize: "0.75rem", backgroundColor: "#e0f2fe", color: "#0369a1", padding: "2px 8px", borderRadius: "12px", fontWeight: 600 }}>{labels.heading}</span>
81
138
 
82
139
  <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>
140
+ <span style={{ fontWeight: 600, color: "#334155" }}>{labels.permissions.label}</span>
84
141
  <span style={{ display: "inline-flex", alignItems: "center", gap: "0.18rem" }}>
85
142
  {permEntries.length === 0 ? (
86
143
  <span data-testid="user-admin-permission-unavailable">
@@ -105,7 +162,7 @@ export function UserAdminShell({ children, renderHeader }: UserAdminShellProps)
105
162
  </div>
106
163
  )}
107
164
 
108
- <div style={{ display: "flex", gap: "0.5rem", marginBottom: "1.2rem", borderBottom: "2px solid #e2e8f0", paddingBottom: "0.5rem" }}>
165
+ <nav aria-label={labels.title} style={{ display: "flex", gap: "0.5rem", marginBottom: "1.2rem", borderBottom: "2px solid #e2e8f0", paddingBottom: "0.5rem" }}>
109
166
  {tabs.map((tab) => {
110
167
  const isActive = segment === tab.key
111
168
  return (
@@ -113,6 +170,7 @@ export function UserAdminShell({ children, renderHeader }: UserAdminShellProps)
113
170
  key={tab.key}
114
171
  to={tab.href}
115
172
  data-testid={`user-admin-tab-${tab.key}`}
173
+ aria-current={isActive ? "page" : undefined}
116
174
  style={{
117
175
  padding: "0.5rem 1rem",
118
176
  borderRadius: "6px 6px 0 0",
@@ -128,7 +186,9 @@ export function UserAdminShell({ children, renderHeader }: UserAdminShellProps)
128
186
  </Link>
129
187
  )
130
188
  })}
131
- </div>
189
+ </nav>
190
+
191
+ <DirectorySyncBanner status={data?.directoryStatus} labels={labels} groups={data?.groups ?? []} />
132
192
 
133
193
  <main>{children}</main>
134
194
  </div>