/** * Showcase · permission-matrix — 権限マトリクス (RBAC role × permission grid) * * A role × permission matrix with a STICKY first column (権限), ✓/— cells, a * two-role COMPARE mode, and a 差分のみ (differences-only) filter — the pattern * DXS role tabs (SCR-203) and the org role editor (SCR-111b) each hand-rolled *. Read-only by default. * * SINCE the grid itself IS a package export — `PermissionMatrix` from * `@godxjp/ui/data-display` (see docs/data-display/permission-matrix.tsx). The * original Gate-0 "composition pattern" verdict was OUTCOME-corrected the same * way ErrorSurface was: a consumer cannot import a docs page, so the * canonical grid needed an importable home. Prefer the export in app code. * * This showcase remains as the COMPOSED form of the same screen — the full * hand-built Table-family version with its own compare controls — for consumers * who need a variant the export does not model. The shared grant/diff DATA * logic stays in the pure, tested `@godxjp/ui/lib/permission-grid` util (no * strings, no React), used identically by the export and by this composition. * * GAP: DataTable/ColumnDef cannot pin the FIRST column (only the header row and a * single trailing `pin:'end'` column). So the sticky-権限 column is composed from * the real Table family primitives with `sticky start-0` + a token edge — exactly * as docs/showcase/table-sticky-columns.tsx does. No hand-rolled , no raw * HTML controls: role pickers = Select, the diff toggle = Switch, cells = Badge. * * DNA: compact density, semantic tokens only (success ✓, muted —, warning 差分), * shape-encoded state (Check vs Minus icon, never colour-only) + sr-only text for * screen readers, quiet JP copy, no emoji. */ import * as React from "react"; import { Check, Minus } from "lucide-react"; import { Badge, Card, CardAction, CardContent, CardHeader, CardTitle, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@godxjp/ui/data-display"; import { FormField, Label, Select, Switch } from "@godxjp/ui/data-entry"; import { Text } from "@godxjp/ui/general"; import { Flex, PageContainer } from "@godxjp/ui/layout"; import { cn } from "@godxjp/ui/lib/utils"; import { type ComparePair, countDifferences, countGrants, grantKey, hasGrant, rolesDifferOnPermission, visibleRows, } from "@godxjp/ui/lib/permission-grid"; // ── ドメイン: ロール(列)と権限(行) ─────────────────────────────────────── type Role = { id: string; name: string; hint: string }; type Permission = { id: string; name: string; description: string; category: string }; const ROLES: Role[] = [ { id: "admin", name: "管理者", hint: "全操作" }, { id: "editor", name: "編集者", hint: "作成・編集" }, { id: "viewer", name: "閲覧者", hint: "閲覧のみ" }, { id: "guest", name: "ゲスト", hint: "限定共有" }, ]; const PERMISSIONS: Permission[] = [ { id: "user.manage", name: "ユーザー管理", description: "メンバーの追加・停止・権限変更", category: "管理", }, { id: "member.invite", name: "メンバー招待", description: "招待リンクの発行", category: "管理" }, { id: "billing.view", name: "請求書の閲覧", description: "請求・支払い履歴の参照", category: "請求", }, { id: "billing.edit", name: "請求書の編集", description: "請求先・明細の変更", category: "請求" }, { id: "report.export", name: "レポート出力", description: "CSV / PDF のエクスポート", category: "レポート", }, { id: "audit.view", name: "監査ログ閲覧", description: "操作履歴の参照", category: "レポート" }, { id: "apikey.issue", name: "APIキー発行", description: "連携用トークンの発行", category: "連携", }, { id: "settings.edit", name: "設定変更", description: "組織設定・SSO の変更", category: "設定" }, ]; /** 付与マトリクス(authz キャッシュと同じ `role permission` タプルの Set)。 */ const GRANTS: ReadonlySet = new Set([ // 管理者: 全権限 ...PERMISSIONS.map((p) => grantKey("admin", p.id)), // 編集者 grantKey("editor", "member.invite"), grantKey("editor", "billing.view"), grantKey("editor", "report.export"), grantKey("editor", "apikey.issue"), // 閲覧者 grantKey("viewer", "billing.view"), grantKey("viewer", "report.export"), grantKey("viewer", "audit.view"), // ゲスト grantKey("guest", "billing.view"), ]); const roleOptions = ROLES.map((r) => ({ value: r.id, label: r.name })); const roleName = (id: string) => ROLES.find((r) => r.id === id)?.name ?? id; // ── セル: ✓ 許可 / — 不許可(shape + sr-only、色のみで状態を示さない) ──────── function GrantCell({ granted }: { granted: boolean }) { return granted ? ( ) : ( ); } // 固定列(先頭 = 権限)— スクロールする本体が下を通るので不透明背景 + トークン端。 const PIN_START = "sticky start-0 z-20 bg-inherit border-e border-border"; export default function Demo() { const [compareA, setCompareA] = React.useState("editor"); const [compareB, setCompareB] = React.useState("viewer"); const [diffOnly, setDiffOnly] = React.useState(false); const compare = React.useMemo(() => [compareA, compareB], [compareA, compareB]); const compared = new Set(compare); const sameRole = compareA === compareB; const rows = visibleRows(PERMISSIONS, GRANTS, { compare, diffOnly: diffOnly && !sameRole }); const diffN = sameRole ? 0 : countDifferences(PERMISSIONS, GRANTS, compare); return ( {/* ── 比較コントロール ── */} 比較 2 つのロールを選ぶと差分を強調表示。「差分のみ」で一致行を隠す。 {sameRole ? ( 同一ロールを比較中 ) : ( <> 0 ? "warning" : "success"} variant="outline"> 差分 {diffN} 件 {roleName(compareA)} vs {roleName(compareB)} )} {/* ── マトリクス本体 ── */} ロール × 権限 権限 {rows.length} / {PERMISSIONS.length} 件 · ロール {ROLES.length}
権限 {ROLES.map((role) => { const isCompared = !sameRole && compared.has(role.id); return ( {role.name} {isCompared && ( {role.id === compareA ? "A" : "B"} )} {role.hint} · {countGrants(PERMISSIONS, GRANTS, role.id)}件 ); })} {rows.map((perm) => { const isDiffRow = !sameRole && rolesDifferOnPermission(GRANTS, compare, perm.id); return ( {perm.name} {isDiffRow && ( 差分 )} {perm.category} · {perm.description} {ROLES.map((role) => { const isCompared = !sameRole && compared.has(role.id); return ( ); })} ); })}
{/* ── 凡例 ── */} 凡例 許可 不許可 差分 比較 A / B で付与が異なる行 ); }