![[管理画面フォーマット開発編 #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 側でセッション検証」という流れを整えましたが、このままでは各ページでユーザー情報を毎回取得する必要があり、効率的ではありません。
前回の記事 【管理画面フォーマット開発編 #2】JWT +Cookie+middlewareで実装するログイン機能 までで「ログイン → Cookie 保存 → middleware で早期蹴り → Server Action 側でセッション検証」という流れを整えましたが、このままでは各ページでユーザー情報を毎回取得する必要があり、効率的ではありません。
そこで本記事では、
AuthProvider
を導入し、Context API を利用してユーザー情報をグローバルに展開する構成を作ります。これにより、Sidebar やページコンポーネントからシームレスにユーザー情報・ロール情報を利用可能になります。txt
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 から即座に参照可能 |
こうした仕組みによって、UI 側の保守性とユーザー体験の両立を目指します。
技術スタック
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 | スキーマ定義と実行時バリデーション |
本記事では、前回の記事 【管理画面フォーマット開発編 #2】JWT +Cookie+middlewareで実装するログイン機能 までのソースコードを引き継いで追加・編集していきます。
1. 要件と設計方針
本章では、ログイン済みユーザー情報をアプリ全体で共有 するための要件整理と設計方針を固めます。実装は次章以降で行えるよう、データの粒度・取得タイミング・型インターフェース・データフローを先に明確化します。
共有するデータと共有しないデータ
「常にどこからでも参照したい最小限の情報」と「必要時にだけ取得する詳細情報」を分けます。PII を最低限に抑え、JWT には個人情報を載せない 前提を維持します。
区分 | キー | 例 | 共有可否 | 取得先 |
---|---|---|---|---|
ユーザー識別 | 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 |
取得タイミングの選定
ユーザー情報をいつ Context に載せるかを比較し、下記のうち(A)を採用します。
案 | タイミング | 長所 | 注意点 |
---|---|---|---|
A | ログイン成功直後(loginAction )で DB からスナップショットを取得し、クライアントへ返す | 初回描画が速い/以降は Context 参照のみ | 途中でロール等が変わった場合の再同期フローを用意(後述) |
B | 各 page.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.ts
の prisma
を単一出所として利用します。リードレプリカが設定されているため、読み取り系はスケール しやすい構成です。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}
prisma
はsrc/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 最小化 で負荷と漏洩リスクを下げる。
次章では、この方針に沿って
AuthProvider
と useAuth()
を実装 し、loginAction
から返すスナップショットを受け取って Context を初期化するところまでを組み立てます。2. AuthProvider と useAuth の実装
本章では、前章で整理した要件に基づき、Context を実際に構築するための
これにより、アプリ全体から「ログイン済みユーザーのスナップショット」を簡単に参照できるようになります。
AuthProvider
と useAuth
フック を実装します。これにより、アプリ全体から「ログイン済みユーザーのスナップショット」を簡単に参照できるようになります。
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 を判定
3 ↓
4[getUserSnapshot()] で DB から最小情報を取得
5 ↓
6<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.tsx
に AuthProviderServer
を挟み込みます。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
による即時参照が可能になります。
この章のまとめ
AuthProvider
とuseAuth
を実装し、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
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
を呼び出してダッシュボードへ遷移します。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")
これでログイン後のページでは
以降は Sidebar のメニュー制御やページ単位の RBAC 判定に、この Context を活用していきます。
useAuth()
からユーザー情報を即座に参照でき、再取得の必要がなくなります。以降は Sidebar のメニュー制御やページ単位の RBAC 判定に、この Context を活用していきます。
5. UIへの適用 ─ Sidebar/ヘッダに Context を接続
本章では、これまでモックで表示していたユーザー名・メール・アバター・ロールを
SSR のガードは従来どおり
AuthContext
(useAuth()
)へ置き換え 、さらに ロール優先度(rolePriority
)でメニューを出し分け できるようにします。SSR のガードは従来どおり
middleware
/ lookupSessionFromCookie()
に任せ、CSR 側の Sidebar / ヘッダでは Context を参照して即座に描画します。変更方針(Before/After)
まずは今回 UI 側で行う差分を表で俯瞰します。
対象 | Before | After |
---|---|---|
NavUser | mockUser を表示 | useAuth() からユーザー表示(未ログイン時は安全にデグレード) |
AppSidebar | mockUser 固定 / メニューは全表示 | 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()
の後に適用し、出し分け済みの tree
を NavMain
へ渡します。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 参照 + 未ログイン時の安全表示)
NavUser
を useAuth()
ベースに変更します。未ログインまたは
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
/AppSidebar
をuseAuth()
へ移行 。モック依存を排除してログイン直後から正しい表示に。rolePriority
による メニュー出し分け をfilterMenuRecordsByPriority()
で実現。- 未ログイン時は UI を安全側へデグレードし、SSR ガード + CSR 表示最適化の責務分離を維持。
6. まとめと次回予告
本章では、ログイン済みユーザー情報を Context API でアプリ全体に共有する仕組み を構築しました。
これにより、Sidebar やヘッダから即座にユーザー名やロール情報を参照でき、さらにロール優先度に基づいたメニューの出し分けも実現しました。
これにより、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 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 |
この記事の執筆・編集担当
DE
松本 孝太郎
DELOGs編集部/中年新米プログラマー
ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。
▼ 関連記事
[管理画面フォーマット開発編 #2] JWT +Cookie+middlewareで実装するログイン機能
httpOnly Cookie と middleware を組み合わせ、JWTはjtiのみを運ぶ“鍵”として使用。法人ユースに耐える堅牢なログインを実装
2025/9/12公開
![[管理画面フォーマット開発編 #2] JWT +Cookie+middlewareで実装するログイン機能のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-login%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #1] Prisma × PostgreSQLで進めるDB設計
管理画面フォーマット(UIのみ版)を土台に、バックエンドの第一弾としてのDB設計
2025/9/10公開
![[管理画面フォーマット開発編 #1] Prisma × PostgreSQLで進めるDB設計のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-prisma-db-design%2Fhero-thumbnail.jpg&w=1200&q=75)
JWTとロールでAPIを守る ─ RBAC導入とGuard関数実装
APIを安全にする鍵は「ロールベースの認可」。JWTのpayloadに含めたロール情報を活用し、Admin専用APIの実装を通じてRBACの基本を実践
2025/8/5公開

Prisma × PostgreSQLで始めるユーザー・ロール管理
スキーマ設計とDB連携の基礎構築を通じて、認可の土台となるユーザー・ロール情報の管理を実践
2025/8/3公開

JWTで保護されたユーザ一覧を実装する ─ 認証・ロール・一覧表示まで
ログイン済みのadminユーザーだけにユーザー一覧を表示します。JWT認証の保護ルートとRBAC導入の第一歩となる実装
2025/7/30公開
