T-CREATOR

shadcn/ui で B2B SaaS ダッシュボードを組む:権限別 UI と監査ログの見せ方

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(usePermissionuseRole)により、権限チェックロジックを一元管理できました。PermissionGateコンポーネントを使うことで、宣言的に権限制御を記述でき、コードの可読性と保守性が大幅に向上します。

権限マトリックスを一箇所で定義することで、ビジネスルールの変更にも柔軟に対応できるようになるでしょう。

監査ログについて

shadcn/ui の Table コンポーネントを基盤に、フィルタリング、検索、詳細表示機能を持つ監査ログ画面を実装しました。ユーザーアクションの自動記録、リアルタイム通知、エクスポート機能により、コンプライアンス要件を満たす堅牢な監査システムが構築できます。

期待される効果

この実装パターンを採用することで、以下のメリットが得られます。

#項目効果
1コード品質権限ロジックの一元管理により、バグの混入リスクが低減
2開発効率再利用可能なコンポーネントにより、新機能開発が高速化
3セキュリティ一貫した権限チェックにより、セキュリティホールを防止
4コンプライアンス詳細な監査ログにより、各種規制要件に対応可能
5ユーザー体験権限に応じた適切な UI により、使いやすさが向上

今後の拡張可能性

今回の実装をベースに、以下のような拡張が可能です。

  • 細粒度権限: リソースレベル、フィールドレベルでの権限制御
  • 動的権限: 組織やプロジェクトごとに異なる権限設定
  • 権限申請ワークフロー: メンバーが権限昇格を申請できる仕組み
  • 監査ログの分析: AI を活用した異常検知や不正アクセス検出
  • 監査レポート: 定期的な監査レポートの自動生成

B2B SaaS アプリケーションにおいて、権限管理と監査ログは単なる機能ではなく、サービスの信頼性を支える重要な基盤です。shadcn/ui の柔軟性を活かして、ユーザーに安心して使ってもらえるダッシュボードを構築していきましょう。

関連リンク