![[管理画面フォーマット開発編 #3] AuthProviderでログイン済みユーザー情報を全体共有](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-auth-provider%2Fhero.jpg&w=3840&q=75)
管理画面フォーマット開発編 #3AuthProviderでログイン済みユーザー情報を全体共有
ログイン成功直後に取得したユーザー情報をAuthProvider(Client Context)でアプリ全体に配布
初回公開日
最終更新日
0. はじめに
前回の記事 【管理画面フォーマット開発編 #2】JWT +Cookie+middlewareで実装するログイン機能 までで「ログイン → Cookie 保存 → middleware で早期蹴り → Server Action 側でセッション検証」という流れを整えましたが、このままでは各ページでユーザー情報を毎回取得する必要があり、効率的ではありません。
AuthProvider を導入し、Context API を利用してユーザー情報をグローバルに展開する構成を作ります。これにより、Sidebar やページコンポーネントからシームレスにユーザー情報・ロール情報を利用可能になります。1【処理フローの全体像】
2
3[loginAction] → JWT発行+User情報返却
4 ↓
5[AuthProviderServer](SSRでセッション確認)
6 ↓
7<AuthProvider>(CSR Context)
8 ↓
9子コンポーネントから useAuth() で参照本記事のゴール
| 区分 | 内容 |
|---|---|
| ユーザー情報 | 名前・メールアドレス・アバター・ロールなどを Context で一元管理 |
| Sidebar制御 | ロールごとに表示するメニューを出し分け可能 |
| 画面制御 | ページ単位で「ADMIN専用」などの RBAC 判定が容易に |
| 効率性 | 各 page.tsx ごとに DB 問い合わせをせず、Context から即座に参照可能 |
管理画面フォーマット開発編の完成形(デモとGithubリポジトリ)

管理画面フォーマット開発編の完成版デモ: https://delogs.jp/demo/dashboard-format
管理画面フォーマット開発編のGihubリポジトリ: https://github.com/delogs-jp/dashboard-format-fullstack
技術スタック
| Tool / Lib | Version | Purpose |
|---|---|---|
| React | 19.x | UIの土台。コンポーネント/フックで状態と表示を組み立てる |
| Next.js | 15.x | フルスタックFW。App Router/SSR/SSG、動的ルーティング、メタデータ管理 |
| TypeScript | 5.x | 型安全・補完・リファクタリング |
| shadcn/ui | latest | RadixベースのUIキット |
| Tailwind CSS | 4.x | ユーティリティファーストCSSで素早くスタイリング |
| Zod | 4.x | スキーマ定義と実行時バリデーション |
1. 要件と設計方針
共有するデータと共有しないデータ
| 区分 | キー | 例 | 共有可否 | 取得先 |
|---|---|---|---|---|
| ユーザー識別 | userId | uuid | 共有する(必須) | Session 由来 |
| 表示用 | name | "山田 太郎" | 共有する | User.name |
| 表示用 | email | "taro@example.com" | 共有する | User.email |
| 表示用 | avatarUrl | "/user-avatar.png" or サインドURL | 共有する | プロファイル由来 |
| 認可 | roleCode | "ADMIN" | "EDITOR" | "VIEWER"等 | 共有する | Role.code |
| 認可 | rolePriority | 100 | 共有する | Role.priority |
| 変動の大きい詳細 | 電話番号・住所・権限フラグ群 | phone, canEditData 等 | 必要時取得 | 個別 Server Action |
取得タイミングの選定
| 案 | タイミング | 長所 | 注意点 |
|---|---|---|---|
| A | ログイン成功直後(loginAction)で DB からスナップショットを取得し、クライアントへ返す | 初回描画が速い/以降は Context 参照のみ | 途中でロール等が変わった場合の再同期フローを用意(後述) |
| B | 各 page.tsx の SSR で毎回取得 | 常に最新 | ページ遷移たびに DB 負荷/同じ取得を繰返す |
| C | 初回ロード時にクライアントで API 叩く | 実装が直感的 | API 依存・Flicker(無認可瞬間描画)のリスク |
データフローの全体像
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() でアクセスガード(従来どおり)型定義(最小インターフェース)
AuthProvider を用意し、useAuth() フックから参照できるようにします。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};AuthProvider 実装時にこの型を使って、受け取ったスナップショットを Context に保存します。refresh は任意(後章で増設予定)です。データ源:Prisma(リードレプリカ対応)
src/lib/database.ts の prisma を単一出所として利用します。リードレプリカが設定されているため、読み取り系はスケール しやすい構成です。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}prismaはsrc/lib/database.tsから import し、ログやリードレプリカ設定を一元化します。selectで必要列だけを取り、Context に積む情報を最小化。- アバターは「直リンク禁止・Next.js 経由で認可配信」という方針に沿い、後続章で安全な URL を発行します。ここでは
nullを返す仮仕様にしています。
ルーティングとガードの整理
lookupSessionFromCookie() → redirect("/") の流れを維持します。Context は CSR で保持するため、SSR/CSR の責務が混ざらない点が重要です。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 最小化 で負荷と漏洩リスクを下げる。
AuthProvider と useAuth() を実装 し、loginAction から返すスナップショットを受け取って Context を初期化するところまでを組み立てます。2. AuthProvider と useAuth の実装
AuthProvider と useAuth フック を実装します。これにより、アプリ全体から「ログイン済みユーザーのスナップショット」を簡単に参照できるようになります。
AuthProvider の責務
| 責務 | 説明 |
|---|---|
| 初期化 | ログイン成功時にサーバから受け取ったユーザースナップショットを Context に格納 |
| 提供 | useAuth() フックを通じて、子コンポーネントから即座に参照可能にする |
useAuth() を呼ぶだけでユーザー名やロールを表示できるようになります。ディレクトリ構成
1src/
2├─ lib/
3│ └─ auth/
4│ ├─ types.ts # AuthUserSnapshot / AuthContextValue
5│ ├─ user-snapshot.ts # DBから最小スナップショット取得
6│ └─ context.tsx # AuthProvider / useAuthContext 実装
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 に渡す仕組みを追加します。1[SSR page.tsx]
2 └─ lookupSessionFromCookie() で有効な userId を判定
3 ↓
4[getUserSnapshot()] で DB から最小情報を取得
5 ↓
6<AuthProvider initialUser={snapshot}> に渡して CSR へ展開Server Wrapper の実装
AuthProvider を呼び出すラッパーを作ります。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.tsx に AuthProviderServer を挟み込みます。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による即時参照が可能になります。
この章のまとめ
AuthProviderとuseAuthを実装し、Context API でユーザー情報を共有できるようにした。- SSR 側でセッション確認を行い、スナップショットを取得する
AuthProviderServerを用意。 (protected)/layout.tsxに組み込むことで、アプリ全体からuseAuth()でログイン済み情報を利用可能にした。
loginAction を改修し、ログイン成功時にユーザースナップショットを返す処理を実装します。3. loginAction の改修 ─ スナップショット返却
loginAction を改修し、 ログイン成功時にユーザースナップショットを返す ようにします。これにより、ログイン直後に
AuthProvider が初期化できるようになり、全ページから即時にユーザー情報を参照できるようになります。変更点の整理
loginAction では「セッション作成 → JWT を Cookie に保存 → ダッシュボードにリダイレクト」という流れでした。今回の改修では、以下の点を追加します。
| 区分 | 従来 | 改修後 |
|---|---|---|
| セッション作成 | DB に Session を保存 | 同じ |
| JWT 保存 | Cookie に保存 | 同じ |
| レスポンス | リダイレクトのみ | ユーザースナップショットも返す |
loginAction の改修
loginAction を改修します。 前章で実装した getUserSnapshot() をここで利用します。セッション作成後に呼び出すことで、DB から最小限のユーザー情報を取得できます。 改修後は、戻り値に
{ ok: true, user: snapshot } を含めるようにします。1// src/app/_actions/auth/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) サーバ側バリデーション(※ loginSchema 側で punycode 正規化済み)
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 // ★ ここで得られる email は「punycode ASCII(大小保持)」、accountId は trim 済み
51 const { accountId, email, password } = parsed.data;
52
53 // 2) 部署を特定
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 },
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 // 4) ロック/無効チェック(ここではカウントは増やさない)
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 // 6) 成功:カウンタ/ロック解除
121 await prisma.user.update({
122 where: { id: user.id },
123 data: { failedLoginCount: 0, lockedUntil: null },
124 });
125
126 // 7) セッション発行(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 // 8) スナップショット取得
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へ移行しますが、ここは次章でやります。処理フローの更新
1[loginAction]
2 ├─ 認証(ID/Email/Password)
3 ├─ Session 作成
4 ├─ JWT 保存(Cookie)
5 └─ ユーザースナップショット返却 ← ★ New
6
7 ↓
8
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 を呼び出してダッシュボードへ遷移します。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/auth/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// ── 以下、省略
39AuthProvider が初期化され、Sidebar や Header で即座にユーザー情報を利用できます。データフローの確認
1[ユーザー] → [LoginForm] → loginAction(Server Action)
2 → セッション保存+ユーザースナップショット返却
3 → AuthProvider.setUser() に渡す
4 → Context 初期化完了
5 → router.push("/dashboard")useAuth() からユーザー情報を即座に参照でき、再取得の必要がなくなります。以降は Sidebar のメニュー制御やページ単位の RBAC 判定に、この Context を活用していきます。
5. UIへの適用 ─ Sidebar/ヘッダに Context を接続
AuthContext(useAuth())へ置き換え 、さらに ロール優先度(rolePriority)でメニューを出し分け できるようにします。SSR のガードは従来どおり
middleware / lookupSessionFromCookie() に任せ、CSR 側の Sidebar / ヘッダでは Context を参照して即座に描画します。変更方針(Before/After)
| 対象 | Before | After |
|---|---|---|
NavUser | mockUser を表示 | useAuth() からユーザー表示(未ログイン時は安全にデグレード) |
AppSidebar | mockUser 固定 / メニューは全表示 | useAuth() の rolePriority でメニューをフィルタ |
| メニュー制御 | なし | minPriority を下回る項目を非表示(親子再帰対応) |
メニューのロール制御ユーティリティ
MenuTree から minPriority を満たさないノードを除外 する関数を用意します。「親が消えると子も消える」「空のセクションは消す」など再帰的に整形します。
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() の後に適用し、出し分け済みの tree を NavMain へ渡します。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 // 依存に使う“安定したプリミティブ”へ切り出し
32 const rolePriority = user?.rolePriority ?? null;
33
34 // 現状はモックストアから毎レンダー取得(将来は Server Action などに置換)
35 const records = getMenus();
36
37 const tree = useMemo(() => {
38 const filtered =
39 rolePriority != null
40 ? filterMenuRecordsByPriority(records, rolePriority)
41 : [];
42 return toMenuTree(filtered);
43 }, [rolePriority, records]);
44
45 return (
46 <Sidebar collapsible="icon" {...props}>
47 <SidebarHeader>
48 <NavTeam team={mockTeam} />
49 </SidebarHeader>
50
51 <SidebarContent>
52 {/* priorityを利用したメニュー表示 */}
53 <nav aria-label="メインメニュー">
54 <NavMain items={tree} />
55 </nav>
56 </SidebarContent>
57
58 <SidebarFooter>
59 <ModeToggle className="ml-auto" />
60 {/* Contextを利用したユーザ情報表示 */}
61 <NavUser />
62 </SidebarFooter>
63
64 <SidebarRail />
65 </Sidebar>
66 );
67}useAuth()からuser?.rolePriorityを参照し、メニュー表示をロール優先度で制御。NavUserには props を渡さず、コンポーネント内でuseAuth()を直接参照する形に揃えました(次節)。
NavUser の改修(Context 参照 + 未ログイン時の安全表示)
NavUser を useAuth() ベースに変更します。未ログインまたは
user が未初期化の場合は、 安全なデフォルト表示 (プレースホルダー)にフォールバックします。1// src/components/sidebar/nav-user.tsx
2"use client";
3import { useTransition } from "react"; // ★ 追加
4import { logoutAction } from "@/app/_actions/auth/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/AppSidebarをuseAuth()へ移行 。モック依存を排除してログイン直後から正しい表示に。rolePriorityによる メニュー出し分け をfilterMenuRecordsByPriority()で実現。- 未ログイン時は UI を安全側へデグレードし、SSR ガード + CSR 表示最適化の責務分離を維持。
6. まとめと次回予告
これにより、Sidebar やヘッダから即座にユーザー名やロール情報を参照でき、さらにロール優先度に基づいたメニューの出し分けも実現しました。
本記事で実現したポイント
| 区分 | 内容 |
|---|---|
| グローバル共有 | AuthProvider + useAuth によるユーザー情報のContext管理 |
| SSR/CSR責務分離 | SSRはセッションガード、CSRはContext参照に特化 |
| RBAC対応 | rolePriorityに基づきSidebarメニューをフィルタリング |
| UI統合 | NavUser/SidebarがDBユーザー情報を即時表示 |
次回予告
- 直リンク禁止の方針 に基づき、
/api/avatar/[userId]のような認可付きエンドポイントを実装 - サインドURLや認可済み配信の仕組みを組み合わせて、セキュアにアバターを提供
- Sidebar やヘッダの
NavUserコンポーネントに組み込み、実際のユーザーごとに動的に反映
参考文献
| 種別 | リンク |
|---|---|
| React Docs: Context API | https://react.dev/reference/react/useContext |
| Next.js App Router Docs | https://nextjs.org/docs/app |
| Zod Official Docs | https://zod.dev |
| TypeScript Handbook | https://www.typescriptlang.org/docs/ |
| Lucide Icons | https://lucide.dev |
| shadcn/ui Docs | https://ui.shadcn.com |
松本 孝太郎
DELOGs編集部/中年新米プログラマー
ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。
2025/11/6
デモ環境とGithubリポジトリについての記述を追加
2025/9/21
ログインのサーバアクションで小文字変換してマッチングする箇所を削除:src/app/_actions/login.ts
2025/9/19
ログイン関連のサーバアクションファイルのディレクトリを調整: src/app/_actions/ → src/app/_actions/auth/
2025/9/16
app-sidebar.tsxの内容を修正:次のワーニング対応: The 'filtered' conditional could make the dependencies of useMemo Hook (at line 40) change on every render.
2025/9/12
初回公開
管理画面で受け付けたパスワード再発行依頼を、Server Action・Shadcn/uiのデータテーブル・メール送信を組み合わせて運用可能なワークフローに統合
2025/10/15公開
グローバルで一貫したMenuテーブルを保ちながら、部署ごとにメニュー表示をカスタマイズ
2025/10/12公開
DepartmentRole導入に伴い、プロフィール管理で「実効ロール」を参照するように修正と一部ついでの変更
2025/10/8公開
DepartmentRole導入に伴い、ユーザ管理で「実効ロール」を参照するように修正
2025/10/5公開
部署ごとのロールを実際に操作できるように、Server Actionと管理画面UIを構築
2025/10/2公開
