shadcn/ui で B2B SaaS ダッシュボードを組む:権限別 UI と監査ログの見せ方
B2B SaaS アプリケーションでは、企業の組織構造に合わせた権限管理と、コンプライアンス要件を満たす監査ログが欠かせません。shadcn/ui は、このような複雑な要件を持つダッシュボードを美しく、かつ保守性高く実装できる強力なツールです。
今回は、shadcn/ui を活用して、ユーザーの権限に応じて動的に変化する UI と、セキュリティ監査に対応した監査ログ画面の実装方法をご紹介します。実際の B2B SaaS で求められる実践的なパターンを、コードとともに解説していきましょう。
背景
B2B SaaS における権限管理の重要性
企業向けの SaaS アプリケーションでは、組織の階層構造や役割に応じて、ユーザーが操作できる範囲を厳密に制御する必要があります。
一般的な B2B SaaS では、以下のような権限レベルが存在します。
- オーナー(Owner): 組織全体の設定変更、課金管理、メンバー管理
- 管理者(Admin): チーム管理、設定変更、レポート閲覧
- メンバー(Member): 通常業務の実行、データ閲覧・編集
- 閲覧者(Viewer): データの閲覧のみ
これらの権限に応じて、表示するメニュー項目、ボタンの表示/非表示、編集可能なフィールドを動的に切り替える必要があるのです。
shadcn/ui が選ばれる理由
shadcn/ui は、コンポーネントをプロジェクトに直接コピーして使用する設計思想を持っています。このアプローチには以下のメリットがあります。
- 完全なカスタマイズ性: コンポーネントのソースコードを直接編集可能
- バンドルサイズの最適化: 必要なコンポーネントのみを含められる
- 型安全性: TypeScript との統合が優れている
- アクセシビリティ: Radix UI をベースにしており、ARIA 対応が標準
B2B SaaS のような複雑な要件では、この柔軟性が非常に重要になってきます。
以下の図は、shadcn/ui を用いた B2B SaaS ダッシュボードの全体構成を示しています。
mermaidflowchart TB
user["ユーザー<br/>(権限レベル保持)"]
auth["認証層<br/>Next.js Middleware"]
layout["レイアウト<br/>サイドバー + ヘッダー"]
rbac["RBAC チェック<br/>権限判定ロジック"]
dashA["管理者<br/>ダッシュボード"]
dashM["メンバー<br/>ダッシュボード"]
dashV["閲覧者<br/>ダッシュボード"]
auditLog["監査ログ<br/>shadcn/ui Table"]
user -->|ログイン| auth
auth -->|権限情報付与| layout
layout --> rbac
rbac -->|Admin| dashA
rbac -->|Member| dashM
rbac -->|Viewer| dashV
dashA -.->|アクション記録| auditLog
dashM -.->|アクション記録| auditLog
図で理解できる要点:
- ユーザーの権限レベルに応じて表示するダッシュボードが分岐
- 認証層でセキュリティを担保
- すべてのアクションが監査ログに記録される仕組み
課題
B2B SaaS ダッシュボード実装の一般的な課題
権限別 UI と監査ログを実装する際、以下のような課題に直面することが多いでしょう。
課題 1: 権限チェックのコードが散在する
プロジェクトが大きくなるにつれて、if (user.role === 'admin') のような権限チェックがコンポーネント内に散在し、保守が困難になります。権限ロジックの変更が発生した際、修正箇所が膨大になってしまうのです。
課題 2: UI の一貫性を保ちにくい
権限によって表示/非表示を切り替える際、コンポーネントごとに異なる実装方法を取ってしまい、UI の一貫性が失われがちです。ボタンの無効化、メニューの非表示、フィールドのリードオンリー化など、制御方法がバラバラになってしまいます。
課題 3: 監査ログのデータ量と表示パフォーマンス
監査ログは時間経過とともに大量のレコードが蓄積されます。数万〜数十万件のログを効率的にフィルタリング、検索、表示する必要があるでしょう。
以下の図は、課題のある実装パターンを示しています。
mermaidflowchart LR
comp1["コンポーネントA<br/>権限チェック"]
comp2["コンポーネントB<br/>権限チェック"]
comp3["コンポーネントC<br/>権限チェック"]
comp4["コンポーネントD<br/>権限チェック"]
issue["課題:<br/>コード重複<br/>保守困難<br/>不整合リスク"]
comp1 --> issue
comp2 --> issue
comp3 --> issue
comp4 --> issue
style issue fill:#ffcccc
図で理解できる要点:
- 各コンポーネントで個別に権限チェックを実装すると、重複コードが大量発生
- ロジック変更時の影響範囲が広く、バグの温床になる
- 権限判定の一元管理が必要
課題 4: 監査ログの検索性とフィルタリング
ユーザーが特定の操作やイベントを追跡したい場合、日時範囲、実行ユーザー、アクションタイプなど、複数の条件でフィルタリングできる必要があります。しかし、これらの機能を使いやすく実装するのは意外と難しいのです。
解決策
権限管理の一元化と Hooks パターン
権限チェックロジックを一箇所に集約し、カスタム Hooks として提供することで、コードの重複を排除し、保守性を高めます。
以下の実装では、権限判定ロジックを再利用可能な形で提供していきましょう。
型定義: 権限レベルと権限設定
まず、TypeScript で権限関連の型を定義します。
typescript// types/role.ts
/**
* ユーザーの権限レベルを定義
* Owner > Admin > Member > Viewer の階層構造
*/
export type UserRole =
| 'owner'
| 'admin'
| 'member'
| 'viewer';
/**
* 各機能に対するアクション権限
*/
export type Permission = {
read: boolean; // 閲覧権限
write: boolean; // 編集権限
delete: boolean; // 削除権限
manage: boolean; // 管理権限(設定変更など)
};
/**
* ユーザー情報の型定義
*/
export interface User {
id: string;
name: string;
email: string;
role: UserRole;
organizationId: string;
}
型定義により、権限レベルとアクション権限が明確になりました。
権限設定の定義
次に、各役割に対する権限マッピングを定義します。
typescript// lib/permissions.ts
import type { UserRole, Permission } from '@/types/role';
/**
* 役割ごとの権限マトリックス
* 各機能に対してどの役割がどのアクションを実行できるかを定義
*/
export const ROLE_PERMISSIONS: Record<
UserRole,
Permission
> = {
owner: {
read: true,
write: true,
delete: true,
manage: true,
},
admin: {
read: true,
write: true,
delete: true,
manage: false, // オーナー専用機能は管理不可
},
member: {
read: true,
write: true,
delete: false,
manage: false,
},
viewer: {
read: true,
write: false,
delete: false,
manage: false,
},
};
この権限マトリックスを一箇所で管理することで、権限ルールの変更が容易になります。
カスタム Hooks: usePermission
権限チェックを簡単に行えるカスタム Hooks を実装しましょう。
typescript// hooks/usePermission.ts
import { useMemo } from 'react';
import { useSession } from 'next-auth/react';
import { ROLE_PERMISSIONS } from '@/lib/permissions';
import type { UserRole } from '@/types/role';
/**
* 権限チェックを行うカスタムHook
* コンポーネント内で簡単に権限判定ができる
*/
export function usePermission() {
const { data: session } = useSession();
const userRole = session?.user?.role as
| UserRole
| undefined;
// ユーザーの権限情報をメモ化
const permissions = useMemo(() => {
if (!userRole) {
return {
read: false,
write: false,
delete: false,
manage: false,
};
}
return ROLE_PERMISSIONS[userRole];
}, [userRole]);
return permissions;
}
この Hooks を使うことで、コンポーネント内で const { write, delete: canDelete } = usePermission() のように簡潔に権限を取得できます。
カスタム Hooks: useRole
役割ベースの条件分岐を簡単にする Hooks も用意します。
typescript// hooks/useRole.ts
import { useSession } from 'next-auth/react';
import type { UserRole } from '@/types/role';
/**
* ユーザーの役割を取得・判定するカスタムHook
*/
export function useRole() {
const { data: session } = useSession();
const role = session?.user?.role as UserRole | undefined;
/**
* 指定した役割かどうかをチェック
*/
const isRole = (targetRole: UserRole): boolean => {
return role === targetRole;
};
/**
* 指定した役割以上の権限を持っているかチェック
* 権限の階層: owner > admin > member > viewer
*/
const hasRoleOrHigher = (
targetRole: UserRole
): boolean => {
if (!role) return false;
const hierarchy: UserRole[] = [
'viewer',
'member',
'admin',
'owner',
];
const userLevel = hierarchy.indexOf(role);
const targetLevel = hierarchy.indexOf(targetRole);
return userLevel >= targetLevel;
};
return {
role,
isRole,
hasRoleOrHigher,
isOwner: role === 'owner',
isAdmin: role === 'admin',
isMember: role === 'member',
isViewer: role === 'viewer',
};
}
階層的な権限チェックが可能になり、「管理者以上の権限を持つか」といった判定が簡単になりました。
権限別 UI コンポーネントの実装
権限に応じて動的に変化する UI コンポーネントを、shadcn/ui を使って実装していきます。
権限制御コンポーネント: PermissionGate
特定の権限を持つユーザーにのみコンポーネントを表示するラッパーコンポーネントです。
typescript// components/permission-gate.tsx
import { ReactNode } from 'react';
import { usePermission } from '@/hooks/usePermission';
type PermissionType =
| 'read'
| 'write'
| 'delete'
| 'manage';
interface PermissionGateProps {
children: ReactNode;
require: PermissionType;
fallback?: ReactNode;
}
/**
* 権限に基づいてコンポーネントの表示を制御
* 権限がない場合はfallbackを表示、またはnullを返す
*/
export function PermissionGate({
children,
require,
fallback = null,
}: PermissionGateProps) {
const permissions = usePermission();
if (!permissions[require]) {
return <>{fallback}</>;
}
return <>{children}</>;
}
このコンポーネントを使うことで、権限チェックを宣言的に記述できます。
サイドバーメニューの実装
権限に応じてメニュー項目を動的に表示するサイドバーを実装しましょう。
typescript// components/dashboard-sidebar.tsx
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useRole } from '@/hooks/useRole';
import {
LayoutDashboard,
Users,
Settings,
FileText,
Shield,
} from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* メニュー項目の型定義
*/
interface MenuItem {
label: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
minRole?: 'owner' | 'admin' | 'member' | 'viewer';
}
サイドバーの型定義により、各メニュー項目に必要な最小権限を指定できます。
typescript// components/dashboard-sidebar.tsx (続き)
/**
* 権限別サイドバーコンポーネント
*/
export function DashboardSidebar() {
const pathname = usePathname();
const { hasRoleOrHigher } = useRole();
// メニュー項目の定義
const menuItems: MenuItem[] = [
{
label: 'ダッシュボード',
href: '/dashboard',
icon: LayoutDashboard,
minRole: 'viewer', // 全員が閲覧可能
},
{
label: 'メンバー管理',
href: '/dashboard/members',
icon: Users,
minRole: 'admin', // 管理者以上
},
{
label: '監査ログ',
href: '/dashboard/audit-logs',
icon: Shield,
minRole: 'admin', // 管理者以上
},
{
label: 'レポート',
href: '/dashboard/reports',
icon: FileText,
minRole: 'member', // メンバー以上
},
{
label: '組織設定',
href: '/dashboard/settings',
icon: Settings,
minRole: 'owner', // オーナーのみ
},
];
// 権限でフィルタリング
const visibleItems = menuItems.filter((item) =>
item.minRole ? hasRoleOrHigher(item.minRole) : true
);
return (
<aside className='w-64 border-r bg-background'>
<nav className='space-y-1 p-4'>
{visibleItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted'
)}
>
<Icon className='h-4 w-4' />
{item.label}
</Link>
);
})}
</nav>
</aside>
);
}
ユーザーの権限に応じて、表示されるメニュー項目が自動的に調整されます。
以下の図は、権限別 UI 制御の仕組みを示しています。
mermaidflowchart TB
hook["useRole / usePermission<br/>Hooks"]
gate["PermissionGate<br/>コンポーネント"]
menu["DashboardSidebar<br/>メニュー"]
check{{"権限チェック"}}
showUI["UI表示"]
hideUI["UI非表示<br/>or Fallback"]
hook --> gate
hook --> menu
gate --> check
menu --> check
check -->|権限あり| showUI
check -->|権限なし| hideUI
style hook fill:#e1f5ff
style check fill:#fff4e1
図で理解できる要点:
- カスタム Hooks で権限情報を一元管理
- PermissionGate で宣言的に権限制御
- 権限チェックの結果に応じて UI を動的に切り替え
監査ログの実装
shadcn/ui の Table コンポーネントを使って、検索・フィルタリング機能を持つ監査ログ画面を実装します。
監査ログの型定義
typescript// types/audit-log.ts
/**
* 監査ログのアクションタイプ
*/
export type AuditAction =
| 'user.login'
| 'user.logout'
| 'user.created'
| 'user.updated'
| 'user.deleted'
| 'role.changed'
| 'settings.updated'
| 'data.created'
| 'data.updated'
| 'data.deleted';
/**
* 監査ログエントリの型定義
*/
export interface AuditLog {
id: string;
timestamp: Date;
userId: string;
userName: string;
userEmail: string;
action: AuditAction;
resource: string; // 操作対象リソース
resourceId: string;
ipAddress: string;
userAgent: string;
status: 'success' | 'failure';
details?: Record<string, any>; // 追加情報
}
監査ログに必要な情報を網羅的に定義しました。
監査ログテーブルコンポーネント
shadcn/ui の Table コンポーネントを使って、監査ログを表示します。
typescript// components/audit-log-table.tsx
import { useState } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ChevronDown, ChevronUp } from 'lucide-react';
import type { AuditLog } from '@/types/audit-log';
import { formatDistanceToNow } from 'date-fns';
import { ja } from 'date-fns/locale';
interface AuditLogTableProps {
logs: AuditLog[];
}
まずは必要なインポートと型定義を行います。
typescript// components/audit-log-table.tsx (続き)
/**
* 監査ログテーブルコンポーネント
* フィルタリング、ソート、詳細表示機能を持つ
*/
export function AuditLogTable({
logs,
}: AuditLogTableProps) {
const [expandedId, setExpandedId] = useState<
string | null
>(null);
/**
* ステータスに応じたバッジの色を返す
*/
const getStatusVariant = (status: AuditLog['status']) => {
return status === 'success' ? 'default' : 'destructive';
};
/**
* アクションタイプを日本語に変換
*/
const getActionLabel = (action: string): string => {
const labels: Record<string, string> = {
'user.login': 'ログイン',
'user.logout': 'ログアウト',
'user.created': 'ユーザー作成',
'user.updated': 'ユーザー更新',
'user.deleted': 'ユーザー削除',
'role.changed': '権限変更',
'settings.updated': '設定更新',
'data.created': 'データ作成',
'data.updated': 'データ更新',
'data.deleted': 'データ削除',
};
return labels[action] || action;
};
return (
<div className='rounded-md border'>
<Table>
<TableHeader>
<TableRow>
<TableHead>日時</TableHead>
<TableHead>ユーザー</TableHead>
<TableHead>アクション</TableHead>
<TableHead>リソース</TableHead>
<TableHead>ステータス</TableHead>
<TableHead className='w-[50px]'></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs.map((log) => (
<>
<TableRow key={log.id}>
<TableCell className='font-mono text-sm'>
{formatDistanceToNow(log.timestamp, {
addSuffix: true,
locale: ja,
})}
</TableCell>
<TableCell>
<div className='flex flex-col'>
<span className='font-medium'>
{log.userName}
</span>
<span className='text-xs text-muted-foreground'>
{log.userEmail}
</span>
</div>
</TableCell>
<TableCell>
{getActionLabel(log.action)}
</TableCell>
<TableCell className='font-mono text-sm'>
{log.resource}
</TableCell>
<TableCell>
<Badge
variant={getStatusVariant(log.status)}
>
{log.status}
</Badge>
</TableCell>
<TableCell>
<Button
variant='ghost'
size='sm'
onClick={() =>
setExpandedId(
expandedId === log.id
? null
: log.id
)
}
>
{expandedId === log.id ? (
<ChevronUp className='h-4 w-4' />
) : (
<ChevronDown className='h-4 w-4' />
)}
</Button>
</TableCell>
</TableRow>
{expandedId === log.id && (
<TableRow>
<TableCell
colSpan={6}
className='bg-muted/50'
>
<div className='space-y-2 p-4 text-sm'>
<div className='grid grid-cols-2 gap-4'>
<div>
<span className='font-semibold'>
リソースID:{' '}
</span>
<span className='font-mono'>
{log.resourceId}
</span>
</div>
<div>
<span className='font-semibold'>
IPアドレス:{' '}
</span>
<span className='font-mono'>
{log.ipAddress}
</span>
</div>
</div>
<div>
<span className='font-semibold'>
User Agent:{' '}
</span>
<span className='text-muted-foreground'>
{log.userAgent}
</span>
</div>
{log.details && (
<div>
<span className='font-semibold'>
詳細情報:{' '}
</span>
<pre className='mt-2 rounded bg-background p-2 text-xs'>
{JSON.stringify(
log.details,
null,
2
)}
</pre>
</div>
)}
</div>
</TableCell>
</TableRow>
)}
</>
))}
</TableBody>
</Table>
</div>
);
}
行をクリックすることで詳細情報を展開できる、使いやすいテーブルが完成しました。
フィルター機能の実装
監査ログをフィルタリングするためのコンポーネントを作成します。
typescript// components/audit-log-filters.tsx
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { CalendarIcon, X } from 'lucide-react';
import { format } from 'date-fns';
import { ja } from 'date-fns/locale';
import type { AuditAction } from '@/types/audit-log';
interface AuditLogFiltersProps {
searchQuery: string;
onSearchChange: (value: string) => void;
selectedAction: AuditAction | 'all';
onActionChange: (value: AuditAction | 'all') => void;
dateRange: { from?: Date; to?: Date };
onDateRangeChange: (range: {
from?: Date;
to?: Date;
}) => void;
onClearFilters: () => void;
}
フィルター用の props 型を定義しました。
typescript// components/audit-log-filters.tsx (続き)
/**
* 監査ログフィルターコンポーネント
* 検索、アクション種別、日付範囲でフィルタリング可能
*/
export function AuditLogFilters({
searchQuery,
onSearchChange,
selectedAction,
onActionChange,
dateRange,
onDateRangeChange,
onClearFilters,
}: AuditLogFiltersProps) {
return (
<div className='space-y-4'>
<div className='flex flex-wrap gap-4'>
{/* 検索フィールド */}
<div className='flex-1 min-w-[200px]'>
<Input
placeholder='ユーザー名、メールで検索...'
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
/>
</div>
{/* アクション種別フィルター */}
<Select
value={selectedAction}
onValueChange={(value) =>
onActionChange(value as AuditAction | 'all')
}
>
<SelectTrigger className='w-[180px]'>
<SelectValue placeholder='アクション' />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>すべて</SelectItem>
<SelectItem value='user.login'>
ログイン
</SelectItem>
<SelectItem value='user.logout'>
ログアウト
</SelectItem>
<SelectItem value='user.created'>
ユーザー作成
</SelectItem>
<SelectItem value='user.updated'>
ユーザー更新
</SelectItem>
<SelectItem value='user.deleted'>
ユーザー削除
</SelectItem>
<SelectItem value='role.changed'>
権限変更
</SelectItem>
<SelectItem value='settings.updated'>
設定更新
</SelectItem>
<SelectItem value='data.created'>
データ作成
</SelectItem>
<SelectItem value='data.updated'>
データ更新
</SelectItem>
<SelectItem value='data.deleted'>
データ削除
</SelectItem>
</SelectContent>
</Select>
{/* 日付範囲フィルター */}
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
className='w-[240px] justify-start'
>
<CalendarIcon className='mr-2 h-4 w-4' />
{dateRange.from ? (
dateRange.to ? (
<>
{format(dateRange.from, 'yyyy/MM/dd', {
locale: ja,
})}{' '}
-{' '}
{format(dateRange.to, 'yyyy/MM/dd', {
locale: ja,
})}
</>
) : (
format(dateRange.from, 'yyyy/MM/dd', {
locale: ja,
})
)
) : (
<span>期間を選択</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent
className='w-auto p-0'
align='start'
>
<Calendar
mode='range'
selected={{
from: dateRange.from,
to: dateRange.to,
}}
onSelect={(range) =>
onDateRangeChange({
from: range?.from,
to: range?.to,
})
}
locale={ja}
/>
</PopoverContent>
</Popover>
{/* フィルタークリアボタン */}
<Button
variant='ghost'
size='icon'
onClick={onClearFilters}
>
<X className='h-4 w-4' />
</Button>
</div>
</div>
);
}
shadcn/ui の各種コンポーネントを組み合わせて、直感的なフィルター UI が実現できました。
監査ログページの統合
フィルターとテーブルを統合した、完全な監査ログページを実装します。
typescript// app/dashboard/audit-logs/page.tsx
'use client';
import { useState, useMemo } from 'react';
import { AuditLogTable } from '@/components/audit-log-table';
import { AuditLogFilters } from '@/components/audit-log-filters';
import { PermissionGate } from '@/components/permission-gate';
import type {
AuditLog,
AuditAction,
} from '@/types/audit-log';
/**
* 監査ログページ
* 管理者以上のみアクセス可能
*/
export default function AuditLogsPage() {
// フィルター状態
const [searchQuery, setSearchQuery] = useState('');
const [selectedAction, setSelectedAction] = useState<
AuditAction | 'all'
>('all');
const [dateRange, setDateRange] = useState<{
from?: Date;
to?: Date;
}>({});
// TODO: 実際にはAPIから取得
const [logs, setLogs] = useState<AuditLog[]>([]);
/**
* フィルタリングされたログを計算
* useMemoで再計算を最適化
*/
const filteredLogs = useMemo(() => {
return logs.filter((log) => {
// 検索クエリでフィルタリング
if (searchQuery) {
const query = searchQuery.toLowerCase();
const matchesSearch =
log.userName.toLowerCase().includes(query) ||
log.userEmail.toLowerCase().includes(query);
if (!matchesSearch) return false;
}
// アクション種別でフィルタリング
if (
selectedAction !== 'all' &&
log.action !== selectedAction
) {
return false;
}
// 日付範囲でフィルタリング
if (
dateRange.from &&
log.timestamp < dateRange.from
) {
return false;
}
if (dateRange.to) {
const endOfDay = new Date(dateRange.to);
endOfDay.setHours(23, 59, 59, 999);
if (log.timestamp > endOfDay) {
return false;
}
}
return true;
});
}, [logs, searchQuery, selectedAction, dateRange]);
/**
* フィルターをクリア
*/
const handleClearFilters = () => {
setSearchQuery('');
setSelectedAction('all');
setDateRange({});
};
return (
<PermissionGate require='manage'>
<div className='space-y-6'>
<div>
<h1 className='text-3xl font-bold'>監査ログ</h1>
<p className='text-muted-foreground'>
組織内のすべてのアクションを記録・追跡します
</p>
</div>
<AuditLogFilters
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
selectedAction={selectedAction}
onActionChange={setSelectedAction}
dateRange={dateRange}
onDateRangeChange={setDateRange}
onClearFilters={handleClearFilters}
/>
<div className='text-sm text-muted-foreground'>
{filteredLogs.length} 件のログを表示中
</div>
<AuditLogTable logs={filteredLogs} />
</div>
</PermissionGate>
);
}
PermissionGate でページ全体を保護し、権限のないユーザーはアクセスできないようになっています。
具体例
実際の B2B SaaS ダッシュボードを想定した、具体的な実装例をご紹介します。
ユースケース 1: メンバー管理画面
管理者のみがメンバーの追加・削除を行え、メンバーは閲覧のみ可能な画面を実装しましょう。
typescript// app/dashboard/members/page.tsx
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { PermissionGate } from '@/components/permission-gate';
import { usePermission } from '@/hooks/usePermission';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { Plus, Trash2 } from 'lucide-react';
interface Member {
id: string;
name: string;
email: string;
role: string;
}
メンバー管理に必要な型定義とインポートを行いました。
typescript// app/dashboard/members/page.tsx (続き)
/**
* メンバー管理ページ
* 権限に応じて操作可能な機能が変化する
*/
export default function MembersPage() {
const { write, delete: canDelete } = usePermission();
const [members, setMembers] = useState<Member[]>([
// サンプルデータ
{
id: '1',
name: '山田太郎',
email: 'yamada@example.com',
role: 'admin',
},
{
id: '2',
name: '佐藤花子',
email: 'sato@example.com',
role: 'member',
},
]);
/**
* メンバー削除処理
*/
const handleDelete = async (memberId: string) => {
// TODO: API呼び出し
setMembers((prev) =>
prev.filter((m) => m.id !== memberId)
);
};
return (
<div className='space-y-6'>
<div className='flex items-center justify-between'>
<div>
<h1 className='text-3xl font-bold'>
メンバー管理
</h1>
<p className='text-muted-foreground'>
組織のメンバーを管理します
</p>
</div>
{/* 書き込み権限がある場合のみ表示 */}
<PermissionGate require='write'>
<Button>
<Plus className='mr-2 h-4 w-4' />
メンバーを追加
</Button>
</PermissionGate>
</div>
<div className='rounded-md border'>
<Table>
<TableHeader>
<TableRow>
<TableHead>名前</TableHead>
<TableHead>メールアドレス</TableHead>
<TableHead>権限</TableHead>
<TableHead className='w-[100px]'>
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{members.map((member) => (
<TableRow key={member.id}>
<TableCell className='font-medium'>
{member.name}
</TableCell>
<TableCell>{member.email}</TableCell>
<TableCell>{member.role}</TableCell>
<TableCell>
{/* 削除権限がある場合のみ削除ボタンを表示 */}
{canDelete && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant='ghost' size='sm'>
<Trash2 className='h-4 w-4 text-destructive' />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
メンバーを削除しますか?
</AlertDialogTitle>
<AlertDialogDescription>
{member.name}{' '}
を組織から削除します。
この操作は取り消せません。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
キャンセル
</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
handleDelete(member.id)
}
className='bg-destructive text-destructive-foreground'
>
削除する
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}
権限に応じて「メンバーを追加」ボタンや削除ボタンの表示が動的に変化します。
ユースケース 2: 監査ログ記録の自動化
ユーザーのアクションを自動的に監査ログに記録する仕組みを実装しましょう。
typescript// lib/audit-logger.ts
import type {
AuditAction,
AuditLog,
} from '@/types/audit-log';
/**
* 監査ログを記録するユーティリティ関数
*/
export async function logAuditEvent({
userId,
userName,
userEmail,
action,
resource,
resourceId,
status = 'success',
details,
}: {
userId: string;
userName: string;
userEmail: string;
action: AuditAction;
resource: string;
resourceId: string;
status?: 'success' | 'failure';
details?: Record<string, any>;
}) {
// ブラウザ情報を取得
const ipAddress = await getClientIP();
const userAgent = navigator.userAgent;
const logEntry: Omit<AuditLog, 'id'> = {
timestamp: new Date(),
userId,
userName,
userEmail,
action,
resource,
resourceId,
ipAddress,
userAgent,
status,
details,
};
// TODO: APIエンドポイントに送信
await fetch('/api/audit-logs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(logEntry),
});
}
/**
* クライアントIPアドレスを取得
*/
async function getClientIP(): Promise<string> {
// 実際の実装ではサーバー側で取得することを推奨
return 'client-ip';
}
この関数を使うことで、簡単に監査ログを記録できます。
typescript// app/dashboard/members/actions.ts
import { logAuditEvent } from '@/lib/audit-logger';
import { getServerSession } from 'next-auth';
/**
* メンバー削除アクション
* 削除処理と同時に監査ログを記録
*/
export async function deleteMember(memberId: string) {
const session = await getServerSession();
if (!session?.user) {
throw new Error('Unauthorized');
}
try {
// メンバー削除処理
// TODO: 実際のDB操作
// 成功時の監査ログ記録
await logAuditEvent({
userId: session.user.id,
userName: session.user.name,
userEmail: session.user.email,
action: 'user.deleted',
resource: 'member',
resourceId: memberId,
status: 'success',
});
return { success: true };
} catch (error) {
// 失敗時の監査ログ記録
await logAuditEvent({
userId: session.user.id,
userName: session.user.name,
userEmail: session.user.email,
action: 'user.deleted',
resource: 'member',
resourceId: memberId,
status: 'failure',
details: {
error:
error instanceof Error
? error.message
: 'Unknown error',
},
});
throw error;
}
}
成功・失敗の両方のケースで監査ログが記録され、トレーサビリティが確保されます。
ユースケース 3: リアルタイム監査ログ通知
重要なアクションが発生した際に、管理者にリアルタイムで通知する仕組みを実装します。
typescript// components/audit-log-notification.tsx
'use client';
import { useEffect, useState } from 'react';
import { useRole } from '@/hooks/useRole';
import { toast } from '@/components/ui/use-toast';
import type { AuditLog } from '@/types/audit-log';
/**
* 重要度の高いアクション
* これらのアクションが発生した際に通知を表示
*/
const CRITICAL_ACTIONS = [
'user.deleted',
'role.changed',
'settings.updated',
];
/**
* 監査ログ通知コンポーネント
* WebSocketやServer-Sent Eventsで新しいログを受信し通知
*/
export function AuditLogNotification() {
const { isAdmin, isOwner } = useRole();
const [lastLogId, setLastLogId] = useState<string | null>(
null
);
useEffect(() => {
// 管理者以上のみ通知を受け取る
if (!isAdmin && !isOwner) return;
// TODO: WebSocket接続またはSSE接続
// ここではポーリングの例を示す
const interval = setInterval(async () => {
try {
const response = await fetch(
`/api/audit-logs/latest?after=${lastLogId || ''}`
);
const newLogs: AuditLog[] = await response.json();
newLogs.forEach((log) => {
// 重要なアクションのみ通知
if (CRITICAL_ACTIONS.includes(log.action)) {
toast({
title: '重要なアクションが実行されました',
description: `${
log.userName
} が ${getActionLabel(
log.action
)} を実行しました`,
variant:
log.status === 'failure'
? 'destructive'
: 'default',
});
}
setLastLogId(log.id);
});
} catch (error) {
console.error('Failed to fetch audit logs:', error);
}
}, 5000); // 5秒ごとにポーリング
return () => clearInterval(interval);
}, [isAdmin, isOwner, lastLogId]);
return null; // UIを持たないコンポーネント
}
function getActionLabel(action: string): string {
const labels: Record<string, string> = {
'user.deleted': 'ユーザー削除',
'role.changed': '権限変更',
'settings.updated': '設定更新',
};
return labels[action] || action;
}
このコンポーネントをレイアウトに配置することで、管理者は重要なアクションをリアルタイムで把握できます。
以下の図は、監査ログの記録と通知フローを示しています。
mermaidsequenceDiagram
actor User as ユーザー
participant UI as UI Component
participant Action as Server Action
participant Logger as AuditLogger
participant DB as Database
participant WS as WebSocket
participant Admin as 管理者
User->>UI: アクション実行<br/>(削除など)
UI->>Action: deleteMember()
Action->>DB: データ削除
Action->>Logger: logAuditEvent()
Logger->>DB: ログ保存
alt 重要なアクション
Logger->>WS: イベント送信
WS->>Admin: リアルタイム通知
end
Action-->>UI: 完了
UI-->>User: 結果表示
図で理解できる要点:
- ユーザーのアクションが発生すると自動的に監査ログに記録
- 重要なアクションは管理者にリアルタイム通知
- すべての操作履歴がデータベースに永続化
ユースケース 4: エクスポート機能
監査ログを CSV や JSON 形式でエクスポートする機能を実装します。
typescript// lib/export-audit-logs.ts
import type { AuditLog } from '@/types/audit-log';
import { format } from 'date-fns';
/**
* 監査ログをCSV形式でエクスポート
*/
export function exportToCSV(logs: AuditLog[]): string {
const headers = [
'日時',
'ユーザーID',
'ユーザー名',
'メールアドレス',
'アクション',
'リソース',
'リソースID',
'IPアドレス',
'ステータス',
];
const rows = logs.map((log) => [
format(log.timestamp, 'yyyy-MM-dd HH:mm:ss'),
log.userId,
log.userName,
log.userEmail,
log.action,
log.resource,
log.resourceId,
log.ipAddress,
log.status,
]);
const csvContent = [
headers.join(','),
...rows.map((row) =>
row.map((cell) => `"${cell}"`).join(',')
),
].join('\n');
return csvContent;
}
/**
* 監査ログをJSON形式でエクスポート
*/
export function exportToJSON(logs: AuditLog[]): string {
return JSON.stringify(logs, null, 2);
}
/**
* ファイルダウンロードを実行
*/
export function downloadFile(
content: string,
filename: string,
mimeType: string
) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
エクスポート機能により、監査ログを外部システムでも利用できます。
typescript// components/audit-log-export.tsx
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Download } from 'lucide-react';
import {
exportToCSV,
exportToJSON,
downloadFile,
} from '@/lib/export-audit-logs';
import type { AuditLog } from '@/types/audit-log';
import { format } from 'date-fns';
interface AuditLogExportProps {
logs: AuditLog[];
}
/**
* 監査ログエクスポートボタン
*/
export function AuditLogExport({
logs,
}: AuditLogExportProps) {
const handleExportCSV = () => {
const csv = exportToCSV(logs);
const filename = `audit-logs-${format(
new Date(),
'yyyyMMdd-HHmmss'
)}.csv`;
downloadFile(csv, filename, 'text/csv;charset=utf-8;');
};
const handleExportJSON = () => {
const json = exportToJSON(logs);
const filename = `audit-logs-${format(
new Date(),
'yyyyMMdd-HHmmss'
)}.json`;
downloadFile(json, filename, 'application/json');
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline'>
<Download className='mr-2 h-4 w-4' />
エクスポート
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={handleExportCSV}>
CSV形式でエクスポート
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportJSON}>
JSON形式でエクスポート
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
shadcn/ui の DropdownMenu を使って、直感的なエクスポート UI が実現できました。
まとめ
shadcn/ui を活用した B2B SaaS ダッシュボードにおける権限別 UI と監査ログの実装方法を解説してまいりました。
実装のポイント
権限管理について
カスタム Hooks(usePermission、useRole)により、権限チェックロジックを一元管理できました。PermissionGateコンポーネントを使うことで、宣言的に権限制御を記述でき、コードの可読性と保守性が大幅に向上します。
権限マトリックスを一箇所で定義することで、ビジネスルールの変更にも柔軟に対応できるようになるでしょう。
監査ログについて
shadcn/ui の Table コンポーネントを基盤に、フィルタリング、検索、詳細表示機能を持つ監査ログ画面を実装しました。ユーザーアクションの自動記録、リアルタイム通知、エクスポート機能により、コンプライアンス要件を満たす堅牢な監査システムが構築できます。
期待される効果
この実装パターンを採用することで、以下のメリットが得られます。
| # | 項目 | 効果 |
|---|---|---|
| 1 | コード品質 | 権限ロジックの一元管理により、バグの混入リスクが低減 |
| 2 | 開発効率 | 再利用可能なコンポーネントにより、新機能開発が高速化 |
| 3 | セキュリティ | 一貫した権限チェックにより、セキュリティホールを防止 |
| 4 | コンプライアンス | 詳細な監査ログにより、各種規制要件に対応可能 |
| 5 | ユーザー体験 | 権限に応じた適切な UI により、使いやすさが向上 |
今後の拡張可能性
今回の実装をベースに、以下のような拡張が可能です。
- 細粒度権限: リソースレベル、フィールドレベルでの権限制御
- 動的権限: 組織やプロジェクトごとに異なる権限設定
- 権限申請ワークフロー: メンバーが権限昇格を申請できる仕組み
- 監査ログの分析: AI を活用した異常検知や不正アクセス検出
- 監査レポート: 定期的な監査レポートの自動生成
B2B SaaS アプリケーションにおいて、権限管理と監査ログは単なる機能ではなく、サービスの信頼性を支える重要な基盤です。shadcn/ui の柔軟性を活かして、ユーザーに安心して使ってもらえるダッシュボードを構築していきましょう。
関連リンク
articleshadcn/ui で B2B SaaS ダッシュボードを組む:権限別 UI と監査ログの見せ方
articleshadcn/ui で Command Palette を実装:検索・履歴・キーボードショートカット対応
articleshadcn/ui の asChild/Slot を極める:継承可能な API 設計と責務分離
articleshadcn/ui カラートークン早見表:ブランドカラー最適化&明暗コントラスト基準
articleshadcn/ui を Monorepo(Turborepo/pnpm)に導入するベストプラクティス
articleshadcn/ui と Headless UI/Vanilla Radix を徹底比較:実装量・a11y・可読性の差
articleSvelte のコンパイル出力を読み解く:仮想 DOM なしで速い理由
articleTauri で Markdown エディタを作る:ライブプレビュー・拡張プラグイン対応
articleStorybook で“仕様が生きる”開発:ドキュメント駆動 UI の実践ロードマップ
articleshadcn/ui で B2B SaaS ダッシュボードを組む:権限別 UI と監査ログの見せ方
articleSolidJS の Control Flow コンポーネント大全:Show/For/Switch/ErrorBoundary を使い分け
articleRemix で管理画面テンプレ:表・フィルタ・CSV エクスポートの鉄板構成
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来