デザインについての学習メモブログ

Next.js入門 #13 実践編 ログインユーザーだけに機能を出す — 認証×DBを繋げる

記事内に広告が含まれています。

Next.js入門 #13 実践編 ログインユーザーだけに機能を出す — 認証×DBを繋げる

#9でデータベース、#10で認証をそれぞれ実装しました。

しかし今のままでは、この2つはまだ「別々に存在している」だけです。

ログインしているかどうかと、記事データは何も紐づいていません。

今回は、この2つを繋げます。

具体的には次のような、実際のWebアプリでほぼ必ず必要になる仕様を実装します。

  • 記事に「投稿者(作成者)」を記録する
  • 自分が書いた記事だけ、編集・削除ボタンが表示される
  • 他人の記事を編集・削除しようとしたら、サーバー側で拒否する
  • 下書き(published: false)の記事は、投稿者本人にしか見えない

この記事は#9#10の両方が前提です。

この記事のゴール

  • Postモデルに投稿者(authorId)を追加する
  • 記事一覧・詳細ページで、投稿者本人にだけ編集/削除ボタンを表示する
  • Server Actionsで「本人確認」を行い、他人のデータを操作できないようにする

なぜこれが重要なのか

認証(ログインできる)とDB(データを保存できる)は、それぞれ単体で動いていても、実は片方だけでは大きな意味を持ちません。

  • 認証だけがあっても、ログインした後に「そのユーザーだけの世界」がなければ、単に名前が表示されるだけの機能になってしまいます
  • DBだけがあっても、「誰のデータか」が分からなければ、全員が全員のデータを編集できる状態になってしまいます

この2つを繋げることで初めて、「マイページ」「自分の投稿一覧」「他人には見せない下書き」といった、実際のアプリらしい機能が成立します。

Postモデルに投稿者を追加する

prisma/schema.prismaを編集し、PostUserを関連付けます。

Prisma
// prisma/schema.prisma

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String
  published Boolean  @default(false)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  authorId  String
  author    User     @relation(fields: [authorId], references: [id], onDelete: Cascade)
}

model User {
  id            String    @id
  name          String
  email         String    @unique
  emailVerified Boolean   @default(false)
  image         String?
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt

  sessions      Session[]
  accounts      Account[]
  posts         Post[]
}

User側にposts Post[]を追加し、双方向のリレーションにしている点に注目してください(#10で作ったUserモデルに、この1行を追記する形になります)。

Prismaの仕様上必須

Prismaでは1対多のリレーションを定義する際、Post側のauthorフィールドだけでなく、対になるUser側のフィールドも書かないとスキーマの検証エラーになります。

両方揃って初めて、「あるユーザーが書いた記事一覧」をPrisma側から取得できるようになります。

これにより、「あるユーザーが書いた記事一覧」をPrisma側から簡単に取得できるようになります。

編集できたら、マイグレーションを実行します。

ただし、既存のレコードがある場合

具体的には、#9からここまでの間にすでに記事を投稿している場合、エラーで止まることがあります。

これは、authorIdを**必須(NOT NULL)**カラムとして追加しようとしているのに、すでに存在する行にはauthorIdの値が1つもない、という矛盾が起きているためです。

学習用のデータであれば、DBをリセットするのが簡単です。

Bash
npx prisma migrate reset

このコマンドはDB全体(User / Session / Accountを含む)をリセットするため、#10でログインした情報も消えます。

もう一度Googleでログインし直す必要がありますが、学習用途であれば特に問題はありません。

実行後、改めてマイグレーションします。

Bash
npx prisma migrate dev --name add_post_author

補足

ログイン情報(Userなど)は残したまま、Postテーブルの中身だけを消したい場合は、npx prisma studioを開いてPostテーブルの行を手動で削除する方法もあります。

ただし、学習用プロジェクトであればprisma migrate resetの方が手軽で、結果的につまずきにくいです。

記事作成時に投稿者を記録する

#10で実装したauth.api.getSession()を使い、ログイン中のユーザーIDをauthorIdとして保存します。

TypeScript
// app/posts/actions.ts
"use server";

import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";

export async function createPost(formData: FormData) {
  const session = await auth.api.getSession({ headers: await headers() });

  if (!session?.user) {
    throw new Error("ログインが必要です");
  }

  const title = formData.get("title") as string;
  const content = formData.get("content") as string;
  
  //追加
  if (!title || !content) {
    throw new Error("タイトルと本文は必須です");
  }

  await prisma.post.create({
    data: {
      title,
      content,
      //追加
      authorId: session.user.id,
    },
  });

  revalidatePath("/posts");
}

💡 補足

Auth.js(NextAuth)では、セッションにidを含めるためにcallbacks.sessionで明示的な設定が必要でした。

今回使用しているBetter Authはデフォルトでsession.user.idにユーザーIDの文字列が入っています。追加の設定は不要です。

自分の記事一覧を取得する

「マイページ」的なページとして、自分が書いた記事だけを一覧表示するページを作ります。

TSX
// app/mypage/page.tsx
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";

export default async function MyPage() {
  const session = await auth.api.getSession({ headers: await headers() });

  if (!session?.user) {
    redirect("/");
  }

  const myPosts = await prisma.post.findMany({
    where: { authorId: session.user.id },
    orderBy: { createdAt: "desc" },
  });

  return (
    <div className="space-y-4">
      <h1 className="text-xl font-bold">投稿した記事</h1>
      <ul className="space-y-2">
        {myPosts.map((post) => (
          <li key={post.id} className="flex items-center justify-between">
            <span>{post.title}</span>
            <span className="text-xs text-muted-foreground">
              {post.published ? "公開中" : "下書き"}
            </span>
          </li>
        ))}
      </ul>
    </div>
  );
}

where: { authorId: session.user.id }という条件を加えるだけで、「自分のデータだけ」というフィルタリングが完成します。

つまずきやすいポイント

where: { authorId: session.user.id }の部分で

「オブジェクト リテラルは既知のプロパティのみ指定できます。’authorId’ は型 ‘PostWhereInput’ に存在しません。」

というTypeScriptエラーが出ることがあります。

これはschema.prismaauthorIdを追加した後、Prisma Clientの型がまだ更新されていないのが原因です。

Bash
npx prisma generate

を実行し、それでも直らない場合はエディタのTypeScriptサーバーを再起動してください(VSCodeなら、コマンドパレットから「TypeScript: Restart TS Server」)。

#9の「つまずきやすいポイント」で触れたパターンと根っこは同じで、スキーマを変更するたびに起こりうるので覚えておくと安心です。

💡 補足

Auth.jsには/api/auth/signinという、ライブラリ側が自動生成するログイン画面がありましたが、Better Authにはそれに相当するページがありません。

未ログイン時はトップページ(/)にリダイレクトし、#10#12Headerに配置した「Googleでログイン」ボタンからログインしてもらう、という構成にしています。

編集・削除ボタンを本人にだけ表示する

記事一覧・詳細ページ側でも、投稿者本人かどうかを判定し、UIを出し分けます。

TSX
// app/posts/[id]/page.tsx
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import PostActions from "./post-actions";

export default async function PostDetailPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const session = await auth.api.getSession({ headers: await headers() });
  const post = await prisma.post.findUnique({
    where: { id: Number(id) },
    include: { author: true },
  });

  if (!post) notFound();

  // 下書きは投稿者本人以外には見せない
  if (!post.published && post.authorId !== session?.user?.id) {
    notFound();
  }

  const isOwner = post.authorId === session?.user?.id;

  return (
    <article className="space-y-4">
      <h1 className="text-2xl font-bold">{post.title}</h1>
      <p className="text-sm text-muted-foreground">
        投稿者:{post.author.name}
      </p>
      <p>{post.content}</p>

      {isOwner && (
        <PostActions
          postId={post.id}
          initialTitle={post.title}
          initialContent={post.content}
        />
      )}
    </article>
  );
}

PostActionsはまだ作っていないので、これから作ります。

編集ボタンを押すとインライン編集フォームに切り替わり、送信すると#13後半で実装するupdatePost(Server Action)を呼び出す、という構成です。

TSX
// app/posts/[id]/post-actions.tsx
"use client";

import { useState } from "react";
import { updatePost, deletePost } from "../actions";

export default function PostActions({
  postId,
  initialTitle,
  initialContent,
}: {
  postId: number;
  initialTitle: string;
  initialContent: string;
}) {
  const [isEditing, setIsEditing] = useState(false);

  if (isEditing) {
    return (
      <form
        action={async (formData) => {
          await updatePost(postId, formData);
          setIsEditing(false);
        }}
        className="space-y-2"
      >
        <input
          type="text"
          name="title"
          defaultValue={initialTitle}
          className="w-full rounded-md border p-2"
          required
        />
        <textarea
          name="content"
          defaultValue={initialContent}
          rows={6}
          className="w-full rounded-md border p-2"
          required
        />
        <div className="flex gap-2">
          <button type="submit" className="rounded-md bg-black px-3 py-1 text-sm text-white">
            保存
          </button>
          <button
            type="button"
            onClick={() => setIsEditing(false)}
            className="rounded-md border px-3 py-1 text-sm"
          >
            キャンセル
          </button>
        </div>
      </form>
    );
  }

  return (
    <div className="flex gap-2">
      <button
        onClick={() => setIsEditing(true)}
        className="rounded-md border px-3 py-1 text-sm"
      >
        編集
      </button>
      <form action={() => deletePost(postId)}>
        <button type="submit" className="rounded-md border border-red-500 px-3 py-1 text-sm text-red-600">
          削除
        </button>
      </form>
    </div>
  );
}

updatePost / deletePostは、この直後の「Server Actions側で本人確認を行う」で実装します。

ここではまだ存在しないので、次のセクションまで進めてから動作確認してください。

isEditingtrueのときはインライン編集フォーム、

falseのときは「編集」「削除」ボタンを表示する、

というシンプルな切り替えです。

専用の編集ページ(/posts/[id]/edit)を別途作らずに、詳細ページの中で完結させています。

isOwnerの判定は「見た目を出し分けるため」のものであり、これ単体ではセキュリティ対策になりません。

あくまで補助的なものであり、実際のガードは次に説明するServer Actions側で行います。

「新しい記事を書く」ボタンの表示も制御する

#12Headerに配置した「新しい記事を書く」ボタンは、実はまだ未ログインの人にも表示されたままでした。

ここまででsessionを扱う準備が整ったので、同じ考え方でこのボタンにも出し分けを適用します。

TSX
// components/header.tsx
"use client";

import { usePostDialogStore } from "@/stores/use-post-dialog-store";
import NewPostDialog from "@/app/posts/_components/new-post-dialog";

export default function Header({
  authSlot,
  isLoggedIn,
}: {
  authSlot: React.ReactNode;
  isLoggedIn: boolean;
}) {
  const open = usePostDialogStore((state) => state.open);

  return (
    <header className="flex items-center justify-between p-4">
      <h1 className="text-lg font-bold">ミニブログ</h1>
      <div className="flex items-center gap-3">
        {isLoggedIn && (
          <button
            onClick={open}
            className="rounded-md bg-black px-4 py-2 text-sm text-white"
          >
            新しい記事を書く
          </button>
        )}
        {authSlot}
      </div>
      <NewPostDialog />
    </header>
  );
}

呼び出し元のapp/layout.tsxから、セッションの有無をisLoggedInとして渡します。

TSX
// app/layout.tsx
import Header from "@/components/header";
import AuthButtons from "@/components/auth-buttons";
import { Toaster } from "@/components/ui/sonner";
import { Providers } from "./providers";
//追加
import { auth } from "@/lib/auth";
import { headers } from "next/headers";

//追加 asyncにする
export default async function RootLayout({ children }: { children: React.ReactNode }) {
 //追加
  const session = await auth.api.getSession({ headers: await headers() });

  return (
    <html lang="ja">
      <body className="min-h-full flex flex-col">
        <Providers>
                                          {/* 追加 */}
          <Header authSlot={<AuthButtons />} isLoggedIn={!!session?.user} />
          <main className="flex-1">{children}</main>
          <Toaster />
        </Providers>
      </body>
    </html>
  );
}

これもisOwnerと同じく、あくまでUI上の配慮です。

ボタンを隠しても、それだけで「未ログインでも投稿できてしまう」問題が解決するわけではありません。

実際にログインなしでの投稿を防いでいるのは、#12から実装しているcreatePost内のauth.api.getSession()によるチェックです。

UIでの出し分けとServer Actionsでの実チェックは、必ずセットで実装します。

Server Actions側で本人確認を行う(本当の防衛ライン)

UIでボタンを隠していても、Server Actionsのエンドポイントを直接呼び出されてしまえば、他人の記事を編集・削除できてしまいます。必ずサーバー側でも所有者チェックを行います。

TypeScript
// app/posts/actions.ts

import { redirect } from "next/navigation";//追加

//・・・・createPostは省略
                                  //idからposeIdに変更
export async function updatePost(postId: number, formData: FormData) {
//追記 ↓
  const session = await auth.api.getSession({ headers: await headers() });

  if (!session?.user) {
    throw new Error("ログインが必要です");
  }

  const post = await prisma.post.findUnique({ where: { id: postId } });

  if (!post) {
    throw new Error("記事が見つかりません");
  }

  if (post.authorId !== session.user.id) {
    throw new Error("この記事を編集する権限がありません");
  }
//追記 ↑

  const title = formData.get("title") as string;
  const content = formData.get("content") as string;

  await prisma.post.update({
    where: { id: postId }, //変更
    data: { title, content },
  });

  revalidatePath(`/posts/${postId}`);
}
                                  //idからposeIdに変更
export async function deletePost(postId: number) {
//追記 ↓
  const session = await auth.api.getSession({ headers: await headers() });

  if (!session?.user) {
    throw new Error("ログインが必要です");
  }

  const post = await prisma.post.findUnique({ where: { id: postId } });

  if (!post || post.authorId !== session.user.id) {
    throw new Error("この記事を削除する権限がありません");
  }
//追記 ↑
  await prisma.post.delete({ where: { id: postId } }); //変更

  revalidatePath("/posts");
  redirect("/posts");//追加
}

⚠️ メソッドの引数をidからposeIdに変更しています。(コードを読みやすくするため)

updatedeleteを呼ぶ前に、必ず

  1. ログインしているか
  2. 対象のデータが存在するか
  3. そのデータの所有者が自分かどうか

の3点をチェックする、というパターンを徹底します。

この3ステップは、認証×DBを扱うアプリであればほぼ必ず登場するので、型として覚えてしまうとよいでしょう。

Prismaのwhere条件でまとめて絞り込む方法

上記は分かりやすさのためにfindUnique→チェック→updateという3段階で書きましたが、where条件にauthorIdを含めることで1回のクエリにまとめることもできます。

TypeScript
export async function deletePostShort(postId: number) {
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session?.user) throw new Error("ログインが必要です");

  const result = await prisma.post.deleteMany({
    where: {
      id: postId,
      authorId: session.user.id, // 所有者でなければ0件がヒットし、削除されない
    },
  });

  if (result.count === 0) {
    throw new Error("この記事を削除する権限がありません、または存在しません");
  }

  revalidatePath("/posts");
}

deleteMany / updateManyは「条件に一致した件数」を返すため、count === 0であれば「該当データがない」または「所有者が違う」と判断できます。

クエリ回数を1回に減らせるという利点があります。

動作確認

  1. 2つの異なるGoogleアカウントでそれぞれログインし、それぞれ記事を投稿する
  2. Aのアカウントでログインした状態で、Aが投稿した記事にだけ編集・削除ボタンが表示されることを確認
  3. Bの記事の詳細ページで、編集・削除ボタンが表示されないことを確認
  4. ブラウザの開発者ツールなどからBの記事IDを指定してdeletePostを直接呼び出そうとしても、「権限がありません」エラーになることを確認

まとめ

この記事では、以下を実装しました。

  • PostモデルにauthorIdを追加し、Userとのリレーションを構築
  • 記事作成時に、ログイン中のユーザーIDを投稿者として記録
  • UIでは投稿者本人にだけ編集/削除ボタン、ログインユーザーにだけ「新しい記事を書く」ボタンを表示
  • Server Actionsでは「ログイン確認→データ存在確認→所有者確認」の3段階チェックを徹底

UIでの出し分けとサーバー側での権限チェックは、必ずセットで実装するということを覚えておいてください。片方だけでは、見た目は正しくても実際にはセキュリティホールになってしまいます。

次回の#14 記事コンテンツを強化する — Markdownレンダリングと画像アップロードでは、記事本文をMarkdownで書けるようにし、アイキャッチ画像のアップロード機能を追加します。