MENU

問い合わせ


    【Next.jsで作るオンライン予約サービス 第9回】管理画面を作る(管理者の権限・予約一覧・スペースの料金編集)

    オンライン予約サービスは、利用者向けの画面だけでは運営できません。「今日の予約は何件か」「返金に失敗した予約はないか」「料金を改定したい」といった作業のために、運営側の管理画面が必要になります。この回では、予約の一覧を見られて、スペースの名前・説明・収容人数・料金を直せる管理画面を作ります。管理画面は一般の画面より重い権限を持つので、「誰が入れるか」の決め方と守り方が中心の話題です。

    「管理画面は社内の人しか使わないから、簡単なものでいいと思っていた。でも、変なところから入られたり、間違えて料金を消したりしないか心配…」

    先に結論です。管理画面は「ログインしているか」と「管理者か」を、画面ごと・操作ごとに毎回サーバーで確認するのが基本です。そして、管理者でない人には「権限がありません」ではなく「ページが存在しない(404)」を返すと、管理画面があること自体を知られにくくなります。

    前回の第8回では、予約確認メールとキャンセル・返金を作りました。詳しくは次の記事で解説しています。

    今回はその続きで、運営が予約と返金の状況を確認し、スペースの情報を直せるようにします。

    目次

    この回で作るもの:予約一覧とスペース編集の管理画面

    作る機能は3つです。使ったバージョンは Next.js 16・Zod 4 で、コードは blogs/it_hacchu/series/next-yoyaku/code/ にあります。

    機能内容主なファイル
    管理者の判定ログイン中の会員のうち、設定に書いたメールアドレスの人だけを管理者にするlib/admin.ts
    予約の一覧状態(支払い待ち・確定・取消・期限切れ)で絞り込み、返金の状況も見られるapp/admin/page.tsx、lib/admin-reservations.ts
    スペースの編集名前・説明・収容人数・料金を直す。入力はサーバーで検査するapp/admin/spaces/、lib/admin-spaces.ts、components/space-edit-form.tsx

    今回はデータベースの変更はありません。管理者を「データベースの列」ではなく「環境変数の設定」で決めたためです(理由は次の節で説明します)。

    管理者かどうかの決め方:まずは設定で持つ

    管理者の持たせ方は、大きく2通りあります。

    方法よいところ気をつけること
    環境変数(設定)に管理者のメールアドレスを書く画面から管理者を増やせないので、「最初の管理者を作る入口」という弱点がそもそも無い管理者を変えるには設定の変更と再起動が必要
    データベースの会員表に「役割」の列を持つ管理者を画面から増減できる最初の1人をどう作るか、役割の変更を誰ができるかを別途守る必要がある

    運営者が数人で、入れ替わりが少ないうちは、前者で十分です。今回はこちらにしました。管理者が増えて入れ替わりも出てきたら、役割の列と「役割を変える画面」を足します。その場合も、役割を変える操作自体を管理者に限る点は同じです。

    // lib/admin.ts(抜粋)
    // 管理者のメールアドレスは環境変数 ADMIN_EMAILS(カンマ区切り)で決める
    export function parseAdminEmails(value: string | undefined): string[] {
      return (value ?? "")
        .split(",")
        .map((v) => v.trim().toLowerCase())
        .filter((v) => v !== "");
    }
    
    export function isAdminEmail(email: string, adminEmails: string[] = parseAdminEmails(process.env.ADMIN_EMAILS)): boolean {
      return adminEmails.includes(email.trim().toLowerCase());
    }

    メールアドレスは大文字小文字を区別せず比べます。設定が空のときは、誰も管理者になりません(空の設定で全員が通ってしまう、という事故を防ぐ向きです)。

    設定は .env.example に項目だけ載せています。

    # .env.example(追記分)
    ADMIN_EMAILS=

    権限の確認は「画面ごと・操作ごと」に入れる

    ここが今回いちばん大切な点です。管理画面の入口で一度だけ確認すれば安全、とは限りません。

    📰 出典:Next.js ドキュメント「How to implement authentication in Next.js」

    Next.js の公式ドキュメントでは、レイアウト(複数のページで共通の枠)は画面遷移のたびには再実行されないため、権限の確認をレイアウトだけに置かないよう注意しています。また、Server Actions(画面から呼べるサーバー側の関数)は、公開された API の入口と同じ慎重さで扱い、操作の前に「この人に許可されているか」を確認するよう書かれています(執筆時点:2026年10月)。

    そこで、確認を1つの関数にまとめ、すべてのページとすべての Server Action の先頭で呼びます。

    // lib/admin.ts(抜粋)
    // 未ログインはログイン画面へ。ログイン済みでも管理者でなければ 404(管理画面があること自体を見せない)
    export async function requireAdmin() {
      const user = await getCurrentUser();
      if (!user) redirect("/login");
      if (!isAdminEmail(user.email)) notFound();
      return user;
    }

    ページ側は先頭の1行です。

    // app/admin/page.tsx(抜粋)
    export default async function AdminPage({ searchParams }: { searchParams: Promise<{ status?: string }> }) {
      await requireAdmin(); // layout ではなくページごとに確認する
      const status = parseStatusFilter((await searchParams).status);
      const rows = await listAllReservations(status);
      // ...
    }

    「確認を書き忘れた」が一番起きやすい失敗なので、確認は関数1つに集約し、書くのは1行にしています。

    予約の一覧:URL の値はそのまま使わない

    予約一覧は、状態で絞り込めるようにしました。絞り込みの値は URL の ?status= から届きますが、URL は利用者が自由に書き換えられます。知っている値(4種類)だけを通し、それ以外は「絞り込みなし」として扱います。

    // lib/admin-reservations.ts(抜粋)
    export const RESERVATION_STATUSES = ["pending", "confirmed", "cancelled", "expired"] as const;
    export const MAX_ADMIN_ROWS = 100; // 画面に出す上限。件数が増えたらページ送りを足す(連載では省略)
    
    export function parseStatusFilter(value: string | undefined): ReservationStatus | undefined {
      return RESERVATION_STATUSES.find((s) => s === value);
    }
    
    export async function listAllReservations(status?: ReservationStatus) {
      return getDb()
        .select({
          id: reservations.id,
          startsAt: reservations.startsAt,
          // ...(金額・返金の状況・予約者の連絡先・スペース名)
        })
        .from(reservations)
        .innerJoin(spaces, eq(spaces.id, reservations.spaceId))
        .where(and(status ? eq(reservations.status, status) : undefined))
        .orderBy(desc(reservations.startsAt))
        .limit(MAX_ADMIN_ROWS);
    }

    この一覧には予約者の名前とメールアドレスが含まれます。そのため、この関数は requireAdmin() のあとでしか呼ばない決まりにしています。件数の上限(100件)も付けています。件数が増えたときに画面が重くなるのを防ぐためで、本格的に使うならページ送りと検索を足します。

    返金の状況は「返金失敗(要対応)」と表示します。第8回で作った返金は、失敗しても取消を戻さず、記録を残して人が対応する設計でした。その「人が気づく場所」が、この一覧です。

    スペースの編集:入力は管理画面でもサーバーで検査する

    管理者が入力するのだから間違いは少ない、とは言えません。桁の打ち間違い(2,200円のつもりで220,000円)は、管理者でも起きます。第3回の予約フォームと同じく、Zod でサーバー側の検査を行います。

    // lib/admin-spaces.ts(抜粋)
    export const MAX_HOURLY_PRICE_YEN = 100_000; // 入力ミス(桁の打ち間違い)を止めるための上限
    
    export const spaceEditSchema = z.object({
      id: z.coerce.number().int().positive(),
      name: z.string().trim().min(1, "スペース名を入力してください").max(60, "スペース名は60文字以内で入力してください"),
      description: z.string().trim().min(1, "説明を入力してください").max(400, "説明は400文字以内で入力してください"),
      capacity: z.coerce.number().int("収容人数は整数で入力してください").min(1, "収容人数は1名以上にしてください").max(200, "収容人数は200名以内にしてください"),
      hourlyPriceYen: z.coerce
        .number()
        .int("料金は整数(円)で入力してください")
        .min(100, "料金は100円以上にしてください")
        .max(MAX_HOURLY_PRICE_YEN, `料金は${MAX_HOURLY_PRICE_YEN.toLocaleString("ja-JP")}円以内にしてください`)
        .refine((v) => v % 100 === 0, "料金は100円単位で入力してください"),
    });

    更新の処理は次のとおりです。

    // lib/admin-spaces.ts(抜粋)
    // slug(URL)は変えない。変えると、すでに出回っているリンクが切れる。
    // 料金を変えても、入っている予約の金額(reservations.total_yen)は変わらない。予約時の金額を予約の側に持っているため
    export async function updateSpace(input: SpaceEditInput) {
      const { id, ...values } = input;
      const rows = await getDb().update(spaces).set(values).where(eq(spaces.id, id)).returning({ id: spaces.id });
      return rows.length > 0;
    }

    ここで効いているのが、第6回で決めた「予約時にサーバーが計算した金額を予約に保存する」設計です。料金を改定しても、すでに支払い済みの予約の金額や、キャンセル時の返金額の計算元は変わりません。予約表が「料金表の最新の値」を毎回見に行く作りだと、料金改定のたびに過去の予約の金額が変わってしまいます。

    Server Action は、先頭で管理者を確認してから、検査・更新・画面の更新を行います。

    // app/admin/spaces/actions.ts(抜粋)
    "use server";
    
    export async function saveSpace(_prev: SpaceEditState, formData: FormData): Promise<SpaceEditState> {
      await requireAdmin(); // Server Action は画面を通らずに直接呼べるので、必ず先頭で確認する
      const result = validateSpaceEdit(Object.fromEntries(formData));
      if (!result.ok) return { errors: result.errors };
    
      const found = await updateSpace(result.input);
      if (!found) return { errors: { form: "スペースが見つかりません" } };
    
      revalidatePath("/spaces"); // 利用者向けの一覧・詳細にも反映する
      revalidatePath("/admin/spaces");
      return { errors: {}, saved: true };
    }

    revalidatePath は、「この URL の表示用データは古くなったので作り直してください」と Next.js に伝える関数です。これを呼ばないと、管理画面で料金を直しても、利用者向けの画面に古い料金が残ることがあります。

    動作確認の方法

    ローカルの PostgreSQL 16 で、サンプルの会員を2人作り(管理者と一般会員)、ADMIN_EMAILS に管理者のアドレスだけを入れて確認しました。

    操作結果
    未ログインで /admin を開くログイン画面へ移動した
    一般会員で /admin・/admin/spaces を開くどちらも 404 になった
    管理者で /admin・/admin/spaces を開く表示された。?status= に不正な値を入れても 200 で全件表示
    管理者がフォームで料金に「1550」を送る「料金は100円単位で入力してください」と表示された
    管理者が料金を「1600」にして保存「保存しました。」と表示され、利用者向けの一覧にも反映された
    一般会員の Cookie で、保存の Server Action を直接呼ぶ404 が返り、データベースは変わらなかった
    ログインしていない状態で同じ呼び出しログイン画面への移動が返り、データベースは変わらなかった
    開発環境での確認画面:管理者の予約一覧

    上の画像は開発環境での確認画面です(デザインは未調整で、動作確認用の最小限の表示です)。自動テストは、管理者の判定・状態フィルタ・スペース編集の入力検査・データベースの更新を加えて、全体で107件が成功しました(型チェック・lint・ビルドも成功)。なお、この環境の都合で、ビルドは next build --webpack で確認しています。

    つまずきやすい点・セキュリティ上の注意

    • 確認をレイアウトだけに置かない。 ページと Server Action の両方で確認します。画面に出さないだけでは、直接の呼び出しは止められません。
    • 管理者の操作でも入力を検査する。 画面側の入力欄の制限(HTML の step や min)は親切のためで、守りはサーバーの検査です。
    • 管理画面の存在を教えない。 権限のない人には 404 を返しました。ただし、これは補助的な工夫で、本当の守りは「毎回の確認」です。
    • 本番では追加の対策が要ります。 管理者のログインに二段階認証を付ける、操作の記録(誰がいつ料金を変えたか)を残す、管理画面へのアクセス元を制限する、などです。連載では省略しています。「これで絶対に安全」ということはありません。

    発注者向けメモ:管理画面を頼むときの確認点

    管理画面は「あとから足せばいい」と後回しにされがちですが、運営が始まると一番使う画面です。依頼するときは、次を確認してください。

    • ☐ 管理者になれる人は誰か、どう決めるか(設定か、画面から増減できるか)が決まっている
    • ☐ 管理者の追加・削除の手順が、運用の担当者に引き継がれる
    • ☐ 料金や設定を変えたとき、すでに受けた予約の金額が変わらない作りになっている
    • ☐ 誰がいつ何を変えたかの記録(操作ログ)を残すか決めている
    • ☐ 予約者の個人情報を、管理画面で誰が見られるかが決まっている
    • ☐ 返金の失敗など、人が対応すべきものに気づける場所がある

    開発会社への質問例です。

    • 「管理画面に入れるのは誰で、どうやって決めますか。管理者を増やす手順も教えてください」
    • 「管理画面の権限の確認は、画面ごとと操作ごとの両方に入っていますか」
    • 「料金を変更したら、すでに入っている予約の金額はどうなりますか」
    • 「管理者の操作(料金の変更など)の記録は残せますか。見積もりに含まれていますか」

    管理画面は、機能を足すほど工数が増えやすい部分です。予約の検索・CSV出力・売上集計・権限の細分化(受付担当と経理で見られる範囲を変える、など)は、あとから追加になりやすい項目です。最初に「運営で毎日やる作業」を書き出して、優先順位を付けておくと、見積もりのずれを小さくできます。

    まとめと次回予告

    この回では、管理者を設定で決め、すべてのページと Server Action の先頭で権限を確認する管理画面を作りました。予約の一覧で返金失敗に気づけるようにし、スペースの料金などを、サーバーの検査つきで直せるようにしました。

    次回の第10回では、ここまで作った予約の流れをテストで自動的に確かめます。Vitest に加えて、ブラウザを自動操作する Playwright で、予約から確認までの通しの確認に挑戦します。

    この連載の記事一覧

    この記事は連載「Next.jsで作るオンライン予約サービス」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      目次