DELOGs
[管理画面フォーマット開発編 #3] AuthProviderでログイン済みユーザー情報を全体共有

管理画面フォーマット開発編 #3
AuthProviderでログイン済みユーザー情報を全体共有

ログイン成功直後に取得したユーザー情報をAuthProvider(Client Context)でアプリ全体に配布

初回公開日

最終更新日

0. はじめに

今回の記事では、 ログイン済みユーザー情報をアプリ全体で共有する仕組み を構築します。
前回の記事 【管理画面フォーマット開発編 #2】JWT +Cookie+middlewareで実装するログイン機能 までで「ログイン → Cookie 保存 → middleware で早期蹴り → Server Action 側でセッション検証」という流れを整えましたが、このままでは各ページでユーザー情報を毎回取得する必要があり、効率的ではありません。
そこで本記事では、AuthProvider を導入し、Context API を利用してユーザー情報をグローバルに展開する構成を作ります。これにより、Sidebar やページコンポーネントからシームレスにユーザー情報・ロール情報を利用可能になります。
txt
1【処理フローの全体像】 2 3[loginAction] → JWT発行+User情報返却 45[AuthProviderServer](SSRでセッション確認) 67<AuthProvider>(CSR Context) 89子コンポーネントから useAuth() で参照

本記事のゴール

まずは記事のゴールを明確にします。今回の実装を終えると、以下が実現できます。
区分内容
ユーザー情報名前・メールアドレス・アバター・ロールなどを Context で一元管理
Sidebar制御ロールごとに表示するメニューを出し分け可能
画面制御ページ単位で「ADMIN専用」などの RBAC 判定が容易に
効率性各 page.tsx ごとに DB 問い合わせをせず、Context から即座に参照可能
こうした仕組みによって、UI 側の保守性とユーザー体験の両立を目指します。

技術スタック

Tool / LibVersionPurpose
React19.xUIの土台。コンポーネント/フックで状態と表示を組み立てる
Next.js15.xフルスタックFW。App Router/SSR/SSG、動的ルーティング、メタデータ管理
TypeScript5.x型安全・補完・リファクタリング
shadcn/uilatestRadixベースのUIキット
Tailwind CSS4.xユーティリティファーストCSSで素早くスタイリング
Zod4.xスキーマ定義と実行時バリデーション
本記事では、前回の記事 【管理画面フォーマット開発編 #2】JWT +Cookie+middlewareで実装するログイン機能 までのソースコードを引き継いで追加・編集していきます。

1. 要件と設計方針

本章では、ログイン済みユーザー情報をアプリ全体で共有 するための要件整理と設計方針を固めます。実装は次章以降で行えるよう、データの粒度・取得タイミング・型インターフェース・データフローを先に明確化します。

共有するデータと共有しないデータ

「常にどこからでも参照したい最小限の情報」と「必要時にだけ取得する詳細情報」を分けます。PII を最低限に抑え、JWT には個人情報を載せない 前提を維持します。
区分キー共有可否取得先
ユーザー識別userIduuid共有する(必須)Session 由来
表示用name"山田 太郎"共有するUser.name
表示用email"taro@example.com"共有するUser.email
表示用avatarUrl"/user-avatar.png" or サインドURL共有するプロファイル由来
認可roleCode"ADMIN" | "EDITOR" | "VIEWER"共有するRole.code
認可rolePriority100共有するRole.priority
変動の大きい詳細電話番号・住所・権限フラグ群phone, canEditData必要時取得個別 Server Action

取得タイミングの選定

ユーザー情報をいつ Context に載せるかを比較し、下記のうち(A)を採用します。
タイミング長所注意点
Aログイン成功直後(loginAction)で DB からスナップショットを取得し、クライアントへ返す初回描画が速い/以降は Context 参照のみ途中でロール等が変わった場合の再同期フローを用意(後述)
Bpage.tsx の SSR で毎回取得常に最新ページ遷移たびに DB 負荷/同じ取得を繰返す
C初回ロード時にクライアントで API 叩く実装が直感的API 依存・Flicker(無認可瞬間描画)のリスク

データフローの全体像

Context は CSR のみ で保持し、SSR は従来どおりセッションでガード します。SSR/CSR の責務を分けることで、安全かつ高速な体験を両立します。
txt
1[loginAction (Server Action)] 2 ├─ 認証(Department.code + email + password) 3 ├─ Session 作成 → JWT を Cookie に保存 4 └─ ★ ユーザースナップショットを返す(name/email/role/...) 5 6 ↓(クライアント側) 7 8<AuthProvider> ← 受け取ったスナップショットを Context に格納 9 ├─ useAuth() で name/role 等を即参照(Sidebar, Header, Pages) 10 └─ 必要時は Server Action で詳細情報を追加フェッチ 11 12[SSR page.tsx] 13 └─ lookupSessionFromCookie() でアクセスガード(従来どおり)

型定義(最小インターフェース)

ここでは Context に載せる最小集合 の型だけを先に固めます。実装章で AuthProvider を用意し、useAuth() フックから参照できるようにします。
ts
1// src/lib/auth/types.ts 2export type AuthUserSnapshot = { 3 userId: string; 4 name: string; 5 email: string; 6 avatarUrl: string | null; 7 roleCode: string; // ← Prisma の Role.code に一致 8 rolePriority: number; 9}; 10 11export type AuthContextValue = { 12 ready: boolean; // 初期化済みか 13 user: AuthUserSnapshot | null; // 未ログイン時は null 14 setUser: (user: AuthUserSnapshot | null) => void; // ログイン/ログアウトなどのクライアント操作から呼べるように 15 // 将来用:再同期フロー(ロール変更を反映したい等) 16 refresh?: () => Promise<void>; 17};
上記は 「Context に置く最小限の形」 を明文化しただけで、まだロジックはありません。AuthProvider 実装時にこの型を使って、受け取ったスナップショットを Context に保存します。refresh は任意(後章で増設予定)です。

データ源:Prisma(リードレプリカ対応)

DB アクセスは、src/lib/database.tsprisma単一出所として利用します。リードレプリカが設定されているため、読み取り系はスケール しやすい構成です。
ts
1// 例)ユーザースナップショット取得用の共通関数 2// src/lib/auth/user-snapshot.ts 3import { prisma } from "@/lib/database"; 4import type { AuthUserSnapshot } from "./types"; 5 6/** 7 * セッションで得られた userId から、Context 用の最小スナップショットを作る。 8 * PII を最小化し、重い JOIN は避け、必要な列だけ select する。 9 */ 10export async function getUserSnapshot( 11 userId: string, 12): Promise<AuthUserSnapshot | null> { 13 const user = await prisma.user.findUnique({ 14 where: { id: userId }, 15 select: { 16 id: true, 17 name: true, 18 email: true, 19 role: { 20 select: { code: true, priority: true }, 21 }, 22 // avatar の保存方式は後続章で実装(仮で null) 23 // プロダクションではサインドURLや保護ルートを生成して返す 24 }, 25 }); 26 27 if (!user || !user.role) return null; 28 29 return { 30 userId: user.id, 31 name: user.name, 32 email: user.email, 33 avatarUrl: null, // 後続で実装(/api/avatar/[userId] など) 34 roleCode: user.role.code, 35 rolePriority: user.role.priority, 36 }; 37}
  • prismasrc/lib/database.ts から import し、ログやリードレプリカ設定を一元化します。
  • select で必要列だけを取り、Context に積む情報を最小化
  • アバターは「直リンク禁止Next.js 経由で認可配信」という方針に沿い、後続章で安全な URL を発行します。ここでは null を返す仮仕様にしています。

ルーティングとガードの整理

SSR 側のページ保護はこれまでどおり lookupSessionFromCookie()redirect("/") の流れを維持します。Context は CSR で保持するため、SSR/CSR の責務が混ざらない点が重要です。
txt
1SSR(page.tsx) CSR(AuthProvider / useAuth) 2┌──────────────────┐ ┌──────────────────────────────┐ 3│ lookupSession... │ │ 受け取ったスナップショットを │ 4│ └ redirect("/") │ │ Context に保持(最小集合) │ 5└──────────────────┘ └──────────────────────────────┘ 6 7※ SSR の可否(入場権)はセッションで判定、 8 表示のための軽量情報は CSR の Context から即参照。

この章のまとめ

  • Context に置くのは最小限の「表示/認可用スナップショット」
  • 取得タイミングは「ログイン成功直後」(方針 A)を採用。
  • SSR は従来どおりセッションでガード し、CSR の Context は体験向上 に寄与。
  • DB アクセスは src/lib/database.ts単一出所 prisma を使い、select 最小化 で負荷と漏洩リスクを下げる。
次章では、この方針に沿って AuthProvideruseAuth() を実装 し、loginAction から返すスナップショットを受け取って Context を初期化するところまでを組み立てます。

2. AuthProvider と useAuth の実装

本章では、前章で整理した要件に基づき、Context を実際に構築するための AuthProvideruseAuth フック を実装します。
これにより、アプリ全体から「ログイン済みユーザーのスナップショット」を簡単に参照できるようになります。

AuthProvider の責務

まずは AuthProvider が担う責務を整理します。
責務説明
初期化ログイン成功時にサーバから受け取ったユーザースナップショットを Context に格納
提供useAuth() フックを通じて、子コンポーネントから即座に参照可能にする
これにより、Sidebar や Header、各ページコンポーネントから useAuth() を呼ぶだけでユーザー名やロールを表示できるようになります。

ディレクトリ構成

Auth 関連の Context は以下のように整理します。
txt
1src/ 2├─ lib/ 3│ └─ auth/ 4│ ├─ types.ts # AuthUserSnapshot / AuthContextValue 5│ ├─ user-snapshot.ts # DBから最小スナップショット取得 6│ └─ context.tsx # AuthProvider / useAuth

Context 実装

次に、Context と Provider を用意します。
tsx
1// src/lib/auth/context.tsx 2"use client"; 3 4import { createContext, useContext, useState, useEffect } from "react"; 5import type { AuthContextValue, AuthUserSnapshot } from "./types"; 6 7const AuthContext = createContext<AuthContextValue>({ 8 ready: false, 9 user: null, 10 setUser: () => {}, // デフォルトno-op 11}); 12 13export function AuthProvider({ 14 initialUser, 15 children, 16}: { 17 initialUser: AuthUserSnapshot | null; 18 children: React.ReactNode; 19}) { 20 const [user, setUser] = useState<AuthUserSnapshot | null>(initialUser); 21 const [ready, setReady] = useState(false); 22 23 useEffect(() => { 24 // 初期化完了フラグを立てる 25 setReady(true); 26 }, []); 27 28 const value: AuthContextValue = { 29 ready, 30 user, 31 setUser, 32 refresh: async () => { 33 // 将来: /_actions/auth/refresh などから再取得する仕組みを実装予定 34 setUser(user); 35 }, 36 }; 37 38 return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; 39} 40 41export function useAuth() { 42 return useContext(AuthContext); 43}
  • AuthProvider はログイン時に受け取った initialUser を Context に格納します。
  • useAuth() フックでいつでも Context の値にアクセス可能になります。
  • refresh は将来的な拡張用で、例えば「管理者がロールを変更した後、即時反映したい」といったケースに利用できます。

SSR と CSR の接続

AuthProvider 単体では CSR に限定されます。
そこで、SSR 側でセッションを確認し、ユーザースナップショットを取得してから AuthProvider に渡す仕組みを追加します。
txt
1[SSR page.tsx] 2 └─ lookupSessionFromCookie() で有効な userId を判定 34[getUserSnapshot()] で DB から最小情報を取得 56<AuthProvider initialUser={snapshot}> に渡して CSR へ展開

Server Wrapper の実装

サーバ側でセッション確認を行い、スナップショットを取得してから CSR の AuthProvider を呼び出すラッパーを作ります。
tsx
1// src/lib/auth/provider-server.tsx 2import { lookupSessionFromCookie } from "./session"; 3import { getUserSnapshot } from "./user-snapshot"; 4import { AuthProvider } from "./context"; 5 6export async function AuthProviderServer({ 7 children, 8}: { 9 children: React.ReactNode; 10}) { 11 const session = await lookupSessionFromCookie(); 12 if (!session.ok) { 13 return <AuthProvider initialUser={null}>{children}</AuthProvider>; 14 } 15 16 const snapshot = await getUserSnapshot(session.userId); 17 return <AuthProvider initialUser={snapshot}>{children}</AuthProvider>; 18}
  • SSR 側で lookupSessionFromCookie() を呼び、無効なら initialUser = null を渡します。
  • 有効なら getUserSnapshot() を呼び出して最小限の情報を取得し、AuthProvider に渡します。
  • これにより、CSR 側で初期レンダリング時から useAuth() が正しい情報を参照できます。

ProtectedLayout への組み込み

既存の src/app/(protected)/layout.tsxAuthProviderServer を挟み込みます。
tsx
1// src/app/(protected)/layout.tsx 2import { AppSidebar } from "@/components/sidebar/app-sidebar"; 3import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar"; 4import { Toaster } from "@/components/ui/sonner"; 5import { AuthProviderServer } from "@/lib/auth/provider-server"; 6 7export default function ProtectedLayout({ 8 children, 9}: { 10 children: React.ReactNode; 11}) { 12 return ( 13 <SidebarProvider> 14 <AuthProviderServer> 15 <AppSidebar /> 16 <SidebarInset className="min-w-0"> 17 {/* サイドバー/ヘッダ/パンくずは“各 page.tsx”で自由に */} 18 {children} 19 <Toaster richColors closeButton /> 20 </SidebarInset> 21 </AuthProviderServer> 22 </SidebarProvider> 23 ); 24}
  • (protected) 配下のすべてのページは、ログイン済みユーザー情報を useAuth() 経由で参照可能になります。
  • SSR 側は従来どおり lookupSessionFromCookie() によるガードを維持しますが、CSR 側は AuthContext による即時参照が可能になります。

この章のまとめ

  • AuthProvideruseAuth を実装し、Context API でユーザー情報を共有できるようにした。
  • SSR 側でセッション確認を行い、スナップショットを取得する AuthProviderServer を用意。
  • (protected)/layout.tsx に組み込むことで、アプリ全体から useAuth() でログイン済み情報を利用可能にした。
次章では、loginAction を改修し、ログイン成功時にユーザースナップショットを返す処理を実装します。

3. loginAction の改修 ─ スナップショット返却

本章では、既存の loginAction を改修し、 ログイン成功時にユーザースナップショットを返す ようにします。
これにより、ログイン直後に AuthProvider が初期化できるようになり、全ページから即時にユーザー情報を参照できるようになります。

変更点の整理

従来の loginAction では「セッション作成 → JWT を Cookie に保存 → ダッシュボードにリダイレクト」という流れでした。
今回の改修では、以下の点を追加します。
区分従来改修後
セッション作成DB に Session を保存同じ
JWT 保存Cookie に保存同じ
レスポンスリダイレクトのみユーザースナップショットも返す
これにより、クライアント側はログイン成功直後にユーザー情報を Context に流し込み可能となります。

loginAction の改修

loginAction を改修します。 前章で実装した getUserSnapshot() をここで利用します。
セッション作成後に呼び出すことで、DB から最小限のユーザー情報を取得できます。 改修後は、戻り値に { ok: true, user: snapshot } を含めるようにします。
tsx
1// src/app/_actions/login.ts 2"use server"; 3 4import { prisma } from "@/lib/database"; 5import argon2 from "argon2"; 6import { 7 loginServerSchema, 8 type LoginServerInput, 9} from "@/lib/login/server-schema"; 10import { signSessionJwt } from "@/lib/auth/jwt"; 11import { setSessionCookie, getSessionTtlSeconds } from "@/lib/auth/cookies"; 12import { getUserSnapshot } from "@/lib/auth/user-snapshot"; 13import type { AuthUserSnapshot } from "@/lib/auth/types"; 14 15const LOCK_THRESHOLD = Number(process.env.LOCK_THRESHOLD ?? "5"); 16const LOCK_MINUTES = Number(process.env.LOCK_MINUTES ?? "15"); 17const AUTH_ERROR_MODE = process.env.AUTH_ERROR_MODE ?? "detailed"; // "ambiguous" | "detailed" 18 19type FieldErrors = Partial<Record<keyof LoginServerInput, string>>; 20 21function ambiguousMessage(): string { 22 return "アカウントまたは認証情報が正しくありません"; 23} 24 25function maybeAmbiguous(fieldErrors?: FieldErrors, fallback?: string) { 26 if (AUTH_ERROR_MODE === "ambiguous") { 27 return { ok: false as const, message: ambiguousMessage() }; 28 } 29 if (fieldErrors) return { ok: false as const, fieldErrors }; 30 return { ok: false as const, message: fallback ?? ambiguousMessage() }; 31} 32 33export type LoginActionResult = 34 | { ok: true; user: AuthUserSnapshot } 35 | { ok: false; fieldErrors?: FieldErrors; message?: string }; 36 37export async function loginAction( 38 input: LoginServerInput, 39): Promise<LoginActionResult> { 40 // 1) サーバ側バリデーション 41 const parsed = loginServerSchema.safeParse(input); 42 if (!parsed.success) { 43 const fe: FieldErrors = {}; 44 for (const issue of parsed.error.issues) { 45 const path = issue.path[0] as keyof LoginServerInput; 46 fe[path] = issue.message; 47 } 48 return { ok: false, fieldErrors: fe }; 49 } 50 const { accountId, email, password } = parsed.data; 51 52 // 2) 部署・ユーザー特定(メール正規化) 53 const normEmail = email.trim().toLowerCase(); 54 const department = await prisma.department.findUnique({ 55 where: { code: accountId }, 56 }); 57 if (!department) { 58 // ユーザーが特定できないためカウントはできない。メッセージは項目別 or 曖昧化。 59 return maybeAmbiguous({ accountId: "アカウントIDが見つかりません" }); 60 } 61 62 // 3) ユーザー特定 63 const user = await prisma.user.findFirst({ 64 where: { departmentId: department.id, email: normEmail }, 65 select: { 66 id: true, 67 hashedPassword: true, 68 failedLoginCount: true, 69 lockedUntil: true, 70 isActive: true, 71 }, 72 }); 73 if (!user) { 74 // ここもユーザー未特定のためカウント不可。メッセージは項目別 or 曖昧化。 75 return maybeAmbiguous({ email: "メールアドレスが見つかりません" }); 76 } 77 78 // 3) ロック/無効チェック(ここではカウントは増やさない) 79 if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) { 80 return { 81 ok: false, 82 message: `アカウントがロックされています。時間をおいて再試行してください。`, 83 }; 84 } 85 if (!user.isActive) { 86 return maybeAmbiguous(undefined, "このアカウントは無効化されています。"); 87 } 88 89 // 5) パスワード検証 90 const passwordOk = await argon2.verify(user.hashedPassword, password); 91 if (!passwordOk) { 92 // 失敗:ユーザー特定済みなので +1(原子的) 93 const updated = await prisma.user.update({ 94 where: { id: user.id }, 95 data: { failedLoginCount: { increment: 1 } }, 96 select: { failedLoginCount: true }, 97 }); 98 99 if (updated.failedLoginCount >= LOCK_THRESHOLD) { 100 await prisma.user.update({ 101 where: { id: user.id }, 102 data: { 103 failedLoginCount: 0, // ロック発火時にリセット 104 lockedUntil: new Date(Date.now() + LOCK_MINUTES * 60 * 1000), 105 }, 106 }); 107 return { 108 ok: false, 109 message: `一定回数以上の失敗によりロックされました。${LOCK_MINUTES}分後に再試行してください。`, 110 }; 111 } 112 113 // 閾値に近づいたら曖昧化(もしくはモードが ambiguous なら常に曖昧) 114 if (updated.failedLoginCount >= Math.max(1, LOCK_THRESHOLD - 2)) { 115 return { ok: false, message: ambiguousMessage() }; 116 } 117 return maybeAmbiguous({ password: "パスワードが違います" }); 118 } 119 120 // 5) 成功:カウンタ/ロック解除 121 await prisma.user.update({ 122 where: { id: user.id }, 123 data: { failedLoginCount: 0, lockedUntil: null }, 124 }); 125 126 // 6) セッション発行(DB + JWT + Cookie) 127 const ttl = getSessionTtlSeconds(); 128 const session = await prisma.session.create({ 129 data: { userId: user.id, expiresAt: new Date(Date.now() + ttl * 1000) }, 130 select: { id: true, userId: true }, 131 }); 132 const token = await signSessionJwt({ jti: session.id }, ttl); 133 await setSessionCookie(token); 134 135 // 7) スナップショット取得 136 const snapshot = await getUserSnapshot(user.id); 137 if (!snapshot) 138 return { ok: false, message: "ユーザー情報取得に失敗しました" }; 139 140 return { ok: true, user: snapshot }; 141}
このように改修することで、ログイン直後に ユーザースナップショットが返却される ようになります。
クライアント側では、この結果を AuthProvider に流し込めば即座にグローバル利用が可能になります。
redirect("/dashboard");の部分は、src/components/login/login-form.tsxへ移行しますが、ここは次章でやります。

処理フローの更新

最後に、改修後の全体フローを整理します。
txt
1[loginAction] 2 ├─ 認証(ID/Email/Password) 3 ├─ Session 作成 4 ├─ JWT 保存(Cookie) 5 └─ ユーザースナップショット返却 ← ★ New 6 78 9<AuthProvider initialUser={snapshot}> 10 └─ useAuth() で Sidebar や Pages から即参照可能

この章のまとめ

  • getUserSnapshot() を用いて 最小限のユーザー情報を取得
  • loginAction の戻り値に user を追加し、ログイン直後から Context を初期化可能に
  • 以降のページ遷移では DB 再取得不要で、useAuth() により即参照できる。
次章では、この戻り値を AuthProvider に流し込み、ログイン成功時に Context が初期化される仕組みを実装します。

4. ログインフローとの統合

本章では、前章で実装した AuthProvider を実際のログインフローと接続し、ログイン成功直後にユーザー情報を Context に展開 する仕組みを組み立てます。これにより、ログイン後のページでは都度 DB を叩くことなく、Sidebar や Header から即座にユーザー情報を利用できるようになります。
前章の修正により、サーバアクションの返却値は以下のようになっています。
状態内容
成功時{ ok: true, user: AuthUserSnapshot }
失敗時{ ok: false, fieldErrors?, message? }
redirect("/dashboard") は削除し、リダイレクトはクライアント側で制御します。

クライアント側で Context 初期化

ログインフォームでは、成功レスポンスからスナップショットを受け取り、AuthProvider に渡して Context を初期化します。ここで redirect を呼び出してダッシュボードへ遷移します。
tsx
1// src/components/login/login-form.tsx(一部抜粋) 2"use client"; 3 4import { useRouter } from "next/navigation"; // 追加 5import { useState, useTransition } from "react"; 6import { useForm } from "react-hook-form"; 7import { z } from "zod"; 8import { zodResolver } from "@hookform/resolvers/zod"; 9import { loginSchema } from "@/lib/login/schema"; 10import { loginAction } from "@/app/_actions/login"; 11import { useAuth } from "@/lib/auth/context"; // 追加 12 13// ── 省略 14 15export default function LoginForm() { 16 const [pending, startTransition] = useTransition(); 17 const [globalError, setGlobalError] = useState<string | null>(null); 18 const { setUser } = useAuth(); // 追加 ★ Context API 19 const router = useRouter(); // 追加 20 21 // ── 省略 22 23 const onSubmit = (values: LoginValues) => { 24 25 // ── onSubmitの既存部分省略 26 27 // 全体エラー(ロック/曖昧メッセージなど)はグローバルに表示 28 if (res.message) setGlobalError(res.message); 29 return; 30 } 31 32 // 下記2行を追加: ★ 成功時:Contextに保存してから遷移 33 setUser(res.user); 34 router.push("/dashboard"); 35 }); 36 }; 37 38// ── 以下、省略 39
こうすることで、ログイン直後に AuthProvider が初期化され、Sidebar や Header で即座にユーザー情報を利用できます。

データフローの確認

実装後のデータフローは以下のように整理されます。
txt
1[ユーザー] → [LoginForm] → loginAction(Server Action) 2 → セッション保存+ユーザースナップショット返却 3 → AuthProvider.setUser() に渡す 4 → Context 初期化完了 5 → router.push("/dashboard")
これでログイン後のページでは useAuth() からユーザー情報を即座に参照でき、再取得の必要がなくなります。
以降は Sidebar のメニュー制御やページ単位の RBAC 判定に、この Context を活用していきます。

5. UIへの適用 ─ Sidebar/ヘッダに Context を接続

本章では、これまでモックで表示していたユーザー名・メール・アバター・ロールを AuthContextuseAuth())へ置き換え 、さらに ロール優先度(rolePriority)でメニューを出し分け できるようにします。
SSR のガードは従来どおり middleware / lookupSessionFromCookie() に任せ、CSR 側の Sidebar / ヘッダでは Context を参照して即座に描画します。

変更方針(Before/After)

まずは今回 UI 側で行う差分を表で俯瞰します。
対象BeforeAfter
NavUsermockUser を表示useAuth() からユーザー表示(未ログイン時は安全にデグレード)
AppSidebarmockUser 固定 / メニューは全表示useAuth()rolePriority でメニューをフィルタ
メニュー制御なしminPriority を下回る項目を非表示(親子再帰対応)

メニューのロール制御ユーティリティ

MenuTree から minPriority を満たさないノードを除外 する関数を用意します。
「親が消えると子も消える」「空のセクションは消す」など再帰的に整形します。
ts
1// src/lib/sidebar/menu.rbac.ts 2import type { MenuRecord } from "./menu.schema"; 3 4/** 5 * 親の minPriority は子孫に “継承” され、子側での上書きは不可という仕様。 6 * 有効値 = 親が持っていれば親の値、なければ自分の値(どちらも無ければ undefined) 7 */ 8function inheritMinPriority( 9 parentMin: number | undefined, 10 selfMin: number | undefined, 11): number | undefined { 12 return parentMin ?? selfMin; 13} 14 15type Index = { 16 byId: Map<string, MenuRecord>; 17 childrenOf: Map<string | null, MenuRecord[]>; 18}; 19 20function buildIndex(records: MenuRecord[]): Index { 21 const byId = new Map<string, MenuRecord>(); 22 const childrenOf = new Map<string | null, MenuRecord[]>(); 23 24 for (const r of records) { 25 byId.set(r.displayId, r); 26 const key = r.parentId ?? null; 27 const arr = childrenOf.get(key) ?? []; 28 arr.push(r); 29 childrenOf.set(key, arr); 30 } 31 32 // 兄弟は order 順に 33 for (const [, arr] of childrenOf) { 34 arr.sort((a, b) => a.order - b.order); 35 } 36 37 return { byId, childrenOf }; 38} 39 40/** 41 * MenuRecord[] を rolePriority でフィルタし、空になった見出しは除去する。 42 * - 非アクティブ(isActive=false)は常に除外 43 * - 親が不可なら子孫も不可(自動的に落ちる) 44 * - 見出し(isSection=true)は、最終的に子が 0 件なら除外 45 */ 46export function filterMenuRecordsByPriority( 47 records: MenuRecord[], 48 userPriority: number, 49): MenuRecord[] { 50 const { childrenOf } = buildIndex(records); 51 52 const kept: MenuRecord[] = []; 53 const keptIds = new Set<string>(); 54 55 // 深さ優先:親 → 子へ “継承された minPriority” を渡しながら評価 56 const walk = (parentId: string | null, inheritedMin: number | undefined) => { 57 const children = childrenOf.get(parentId) ?? []; 58 for (const rec of children) { 59 if (!rec.isActive) continue; 60 61 const effMin = inheritMinPriority(inheritedMin, rec.minPriority); 62 const allowed = effMin == null || userPriority >= effMin; 63 if (!allowed) continue; 64 65 kept.push(rec); 66 keptIds.add(rec.displayId); 67 68 // 子孫へは “親の minPriority を優先継承” 69 walk(rec.displayId, effMin); 70 } 71 }; 72 73 // ルートから開始 74 walk(null, undefined); 75 76 // 2 パス目:空見出し除去(子が一件も残っていない見出しは落とす) 77 const hasChild = new Set<string>(); 78 for (const r of kept) { 79 if (r.parentId && keptIds.has(r.parentId)) hasChild.add(r.parentId); 80 } 81 82 const pruned = kept.filter((r) => 83 r.isSection ? hasChild.has(r.displayId) : true, 84 ); 85 86 // order の連番正規化(兄弟ごと) 87 const byParent = new Map<string | null, MenuRecord[]>(); 88 for (const r of pruned) { 89 const key = r.parentId ?? null; 90 const arr = byParent.get(key) ?? []; 91 arr.push(r); 92 byParent.set(key, arr); 93 } 94 for (const [, arr] of byParent) { 95 arr.sort((a, b) => a.order - b.order); 96 arr.forEach((r, i) => (r.order = i)); 97 } 98 99 return pruned; 100}
上記は rolePriority(数値が大きいほど強い権限)で閲覧可能メニューを抽出する単機能です。
既存の toMenuTree() の後段に差し込むことで、UI 側からは 「常に表示可能なツリー」 を受け取れます。

AppSidebar の改修(Context 参照 + メニュー出し分け)

mockUser 依存を解消し、useAuth() でユーザーとロール優先度を取得します。
filterMenuRecordsByPriority()toMenuTree() の後に適用し、出し分け済みの treeNavMain へ渡します。
tsx
1// src/components/sidebar/app-sidebar.tsx 2"use client"; 3 4import { useMemo } from "react"; 5 6import { ModeToggle } from "@/components/sidebar/mode-toggle"; 7import { NavMain } from "@/components/sidebar/nav-main"; 8import { NavUser } from "@/components/sidebar/nav-user"; 9import { NavTeam } from "@/components/sidebar/nav-team"; 10 11import { 12 Sidebar, 13 SidebarContent, 14 SidebarFooter, 15 SidebarHeader, 16 SidebarRail, 17} from "@/components/ui/sidebar"; 18 19import { mockTeam } from "@/lib/sidebar/mock-team"; 20import { getMenus } from "@/lib/sidebar/menu.mock"; // MenuRecord[] を返す 21import { toMenuTree } from "@/lib/sidebar/menu.transform"; // 変換レイヤ 22 23// ← ★ 追加:RBAC フィルタ 24import { filterMenuRecordsByPriority } from "@/lib/sidebar/menu.rbac"; 25// ← ★ 追加:AuthContext から優先度を取得 26import { useAuth } from "@/lib/auth/context"; 27 28export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { 29 const { user } = useAuth(); // user?.rolePriority を使う 30 31 // MenuRecord[] を取得(UIモックストア) 32 const records = getMenus(); 33 34 // RBAC フィルタ 35 // 未ログイン / priority 不明 → メニュー非表示 36 const filtered = user 37 ? filterMenuRecordsByPriority(records, user.rolePriority) 38 : []; 39 40 const tree = useMemo(() => toMenuTree(filtered), [filtered]); 41 42 return ( 43 <Sidebar collapsible="icon" {...props}> 44 <SidebarHeader> 45 <NavTeam team={mockTeam} /> 46 </SidebarHeader> 47 48 <SidebarContent> 49 {/* priorityを利用したメニュー表示 */} 50 <nav aria-label="メインメニュー"> 51 <NavMain items={tree} /> 52 </nav> 53 </SidebarContent> 54 55 <SidebarFooter> 56 <ModeToggle className="ml-auto" /> 57 {/* Contextを利用したユーザ情報表示 */} 58 <NavUser /> 59 </SidebarFooter> 60 61 <SidebarRail /> 62 </Sidebar> 63 ); 64}
  • useAuth() から user?.rolePriority を参照し、メニュー表示をロール優先度で制御
  • NavUser には props を渡さず、コンポーネント内で useAuth() を直接参照する形に揃えました(次節)。

NavUser の改修(Context 参照 + 未ログイン時の安全表示)

NavUseruseAuth() ベースに変更します。
未ログインまたは user が未初期化の場合は、 安全なデフォルト表示 (プレースホルダー)にフォールバックします。
tsx
1// src/components/sidebar/nav-user.tsx 2"use client"; 3import { useTransition } from "react"; // ★ 追加 4import { logoutAction } from "@/app/_actions/logout"; // ★ 追加 5import Link from "next/link"; 6import { 7 Bell, 8 ChevronsUpDown, 9 KeyRound, 10 LogOut, 11 User as UserIcon, 12} from "lucide-react"; 13 14import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 15import { 16 DropdownMenu, 17 DropdownMenuContent, 18 DropdownMenuGroup, 19 DropdownMenuItem, 20 DropdownMenuLabel, 21 DropdownMenuSeparator, 22 DropdownMenuTrigger, 23} from "@/components/ui/dropdown-menu"; 24import { 25 SidebarMenu, 26 SidebarMenuButton, 27 SidebarMenuItem, 28 useSidebar, 29} from "@/components/ui/sidebar"; 30 31import { useAuth } from "@/lib/auth/context"; 32 33export function NavUser() { 34 const { isMobile } = useSidebar(); 35 const [pending, startTransition] = useTransition(); // ★ 追加 36 37 const { user } = useAuth(); 38 39 const name = user?.name ?? "ゲスト"; 40 const email = user?.email ?? ""; 41 const avatarUrl = user?.avatarUrl ?? "/user-avatar.png"; // 後続章で保護配信URLへ置換 42 const initial = name.slice(0, 1); 43 44 const handleLogout = () => { 45 if (pending) return; // ★ 二重実行防止 46 startTransition(async () => { 47 await logoutAction(); // サーバ側で Cookie 削除 + Session 失効 + redirect("/login") 48 }); 49 }; 50 51 return ( 52 <SidebarMenu> 53 <SidebarMenuItem> 54 <DropdownMenu> 55 <DropdownMenuTrigger asChild> 56 <SidebarMenuButton 57 size="lg" 58 className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" 59 aria-label="ユーザーメニューを開く" 60 disabled={!user} // 未ログイン時は操作不可 61 > 62 <Avatar className="h-8 w-8 rounded-lg"> 63 {/* 画像は next/image でもOKだが AvatarImage で十分 */} 64 <AvatarImage src={avatarUrl} alt={name} /> 65 <AvatarFallback className="rounded-lg"> 66 {initial} 67 </AvatarFallback> 68 </Avatar> 69 <div className="grid flex-1 text-left text-sm leading-tight"> 70 <span className="truncate font-medium">{name}</span> 71 <span className="text-muted-foreground truncate text-xs"> 72 {email && ( 73 <span className="text-muted-foreground truncate text-xs"> 74 {email} 75 </span> 76 )} 77 </span> 78 </div> 79 <ChevronsUpDown className="ml-auto size-4" /> 80 </SidebarMenuButton> 81 </DropdownMenuTrigger> 82 83 <DropdownMenuContent 84 className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg" 85 side={isMobile ? "bottom" : "right"} 86 align="end" 87 sideOffset={4} 88 > 89 {/* ヘッダー(ユーザー情報の再掲) */} 90 <DropdownMenuLabel className="p-0 font-normal"> 91 <div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm"> 92 <Avatar className="h-8 w-8 rounded-lg"> 93 <AvatarImage src={avatarUrl} alt={name} /> 94 <AvatarFallback className="rounded-lg"> 95 {initial} 96 </AvatarFallback> 97 </Avatar> 98 <div className="grid flex-1 text-left text-sm leading-tight"> 99 <span className="truncate font-medium">{name}</span> 100 {email && ( 101 <span className="text-muted-foreground truncate text-xs"> 102 {email} 103 </span> 104 )} 105 </div> 106 </div> 107 </DropdownMenuLabel> 108 109 <DropdownMenuSeparator /> 110 111 <DropdownMenuGroup> 112 <DropdownMenuItem asChild> 113 {/* 変更: /profile に差し替え */} 114 <Link href="/profile" className="flex items-center gap-2"> 115 <UserIcon className="size-4" /> 116 ユーザー情報確認 117 </Link> 118 </DropdownMenuItem> 119 120 <DropdownMenuItem asChild> 121 {/* 変更: /profile/password に差し替え */} 122 <Link 123 href="/profile/password" 124 className="flex items-center gap-2" 125 > 126 <KeyRound className="size-4" /> 127 パスワード変更 128 </Link> 129 </DropdownMenuItem> 130 131 <DropdownMenuItem asChild> 132 {/* TODO: /notifications に差し替え */} 133 <Link href="#" className="flex items-center gap-2"> 134 <Bell className="size-4" /> 135 通知 136 </Link> 137 </DropdownMenuItem> 138 </DropdownMenuGroup> 139 140 <DropdownMenuSeparator /> 141 142 {/* ★ ここを Link ではなく onClick で Action 直結 */} 143 <DropdownMenuItem 144 onClick={handleLogout} 145 disabled={pending} 146 className="text-destructive flex cursor-pointer items-center gap-2" 147 aria-disabled={pending} 148 > 149 <LogOut className="size-4" /> 150 {pending ? "ログアウト中..." : "ログアウト"} 151 </DropdownMenuItem> 152 </DropdownMenuContent> 153 </DropdownMenu> 154 </SidebarMenuItem> 155 </SidebarMenu> 156 ); 157}
  • useAuth() によって モック脱却。ログイン直後でも Context 初期化済みのため、即座に氏名/メール/アバターを表示できます。
  • 未ログイン時(user=null)はボタンを disabled にし、リンクも disabled で安全側へ。
  • アバター URL は一時的にダミーへフォールバックしています(直リンク禁止方針に合わせ、次回章で /api/avatar/[userId] 等へ置換予定)。

この章のまとめ

  • NavUser / AppSidebaruseAuth() へ移行 。モック依存を排除してログイン直後から正しい表示に。
  • rolePriority による メニュー出し分けfilterMenuRecordsByPriority() で実現。
  • 未ログイン時は UI を安全側へデグレードし、SSR ガード + CSR 表示最適化の責務分離を維持。

6. まとめと次回予告

本章では、ログイン済みユーザー情報を Context API でアプリ全体に共有する仕組み を構築しました。
これにより、Sidebar やヘッダから即座にユーザー名やロール情報を参照でき、さらにロール優先度に基づいたメニューの出し分けも実現しました。

本記事で実現したポイント

区分内容
グローバル共有AuthProvider + useAuth によるユーザー情報のContext管理
SSR/CSR責務分離SSRはセッションガード、CSRはContext参照に特化
RBAC対応rolePriorityに基づきSidebarメニューをフィルタリング
UI統合NavUser/SidebarがDBユーザー情報を即時表示
これで「ログイン後のUI制御」の基盤が完成しました。

次回予告

次回は、現在ダミーとして扱っている アバター画像の取り扱い をテーマにします。
  • 直リンク禁止の方針 に基づき、/api/avatar/[userId] のような認可付きエンドポイントを実装
  • サインドURLや認可済み配信の仕組みを組み合わせて、セキュアにアバターを提供
  • Sidebar やヘッダの NavUser コンポーネントに組み込み、実際のユーザーごとに動的に反映
これにより、UI 表示の信頼性とセキュリティをさらに高めます。

参考文献

本記事の実装や設計にあたり、以下の公式ドキュメントや資料を参照しました。
種別リンク
React Docs: Context APIhttps://react.dev/reference/react/useContext
Next.js App Router Docshttps://nextjs.org/docs/app
Zod Official Docshttps://zod.dev
TypeScript Handbookhttps://www.typescriptlang.org/docs/
Lucide Iconshttps://lucide.dev
shadcn/ui Docshttps://ui.shadcn.com
この記事の執筆・編集担当
DE

松本 孝太郎

DELOGs編集部/中年新米プログラマー

ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。