はじめに

Reactの画面では、権限に応じてボタンやメニューを出し分けることが多い。
管理者だけ削除ボタンを表示する、編集権限がある人だけ入力欄を有効にする、といった実装である。

ただし、レビューではここを慎重に見る必要がある。
UIでボタンを隠すことは、認可そのものではない。

この記事では、Reactの権限制御をレビューするときに、UI表示とAPI認可の責務をどう分けて確認するか整理する。

まず止めたい実装

次のコードは、roleadmin のときだけ削除ボタンを表示している。

UIだけで権限を制御している例
export function UserRow({ user, currentUser }: Props) {
  async function deleteUser() {
    await fetch(`/api/users/${user.id}`, { method: "DELETE" });
  }

  return (
    <tr>
      <td>{user.name}</td>
      <td>{user.email}</td>
      <td>
        {currentUser.role === "admin" && (
          <button onClick={deleteUser}>削除</button>
        )}
      </td>
    </tr>
  );
}

この表示制御自体は必要かもしれない。
しかし、これだけでは認可にならない。

Comment
@Reviewer: UIで削除ボタンを隠していますが、API側の認可前提が見えません。フロントエンドの表示制御は操作導線の制御であり、削除可否の保証はサーバー側で行う必要があります。

UI制御と認可は責務が違う

レビューでは、次の2つを分けて読む。

領域 責務
React UI 操作できる導線を分かりやすくする
API 実行してよい操作か最終判断する

React側でボタンを隠すのは、誤操作を減らすためには有効である。
しかし、ブラウザの開発者ツールや直接API呼び出しを防ぐものではない。

そのため、権限のレビューでは「UI上見えないから安全」ではなく、API失敗時に安全側へ戻るかまで確認する。

レビュー観点1:権限情報の出どころが明確か

権限制御に使う情報がどこから来ているかを見る。

信頼境界が曖昧な例
const currentUser = JSON.parse(localStorage.getItem("user") ?? "{}");

if (currentUser.role === "admin") {
  return <AdminMenu />;
}

localStorage の値はユーザー側で変更できる。
表示のヒントとして使うことはあっても、重要操作の判断材料として信頼してはいけない。

レビューでは、権限情報が次のように扱われているか確認する。

  • サーバーから取得した現在ユーザー情報か
  • セッション更新時に再取得されるか
  • 古い権限情報で操作できる状態が残らないか
  • API側で同じ権限判定が行われるか
Comment
@Reviewer: 権限判定にlocalStorage上のroleを使っています。これはクライアント側で変更可能な値なので、表示補助以上の前提にしないでください。API側の認可と、現在ユーザー情報の再取得方針を確認したいです。

レビュー観点2:非表示とdisabledの使い分けがあるか

権限がない操作を隠すか、disabledで見せるかは、画面の意味に関わる。

表現 向いているケース
非表示 そのユーザーに関係ない操作
disabled 操作は存在するが今は条件を満たさない
表示してエラー 直前に権限が変わる可能性がある操作

たとえば、一般ユーザーに管理者メニューを見せる必要はない。
一方で、申請ボタンが承認済み状態で押せないなら、disabledで理由を見せた方がよいことがある。

レビューでは、権限制御がUXの説明責務まで持っているかを見る。

disabled理由を明示する例
<button disabled={!canApprove || request.status !== "pending"}>
  承認
</button>
{!canApprove && <p>承認権限がありません</p>}

レビュー観点3:API拒否時に状態を戻しているか

UI側で権限があると判断しても、API側で拒否されることはある。
権限変更、セッション期限切れ、対象データの状態変更が起きるためだ。

API拒否を想定していない例
async function approveRequest(id: string) {
  setRequests(current =>
    current.map(request =>
      request.id === id ? { ...request, status: "approved" } : request
    )
  );

  await fetch(`/api/requests/${id}/approve`, { method: "POST" });
}

この実装では、APIが403を返しても画面上は承認済みになる。
楽観的更新と権限制御が重なると、事故が見えにくい。

API拒否時に再同期する例
async function approveRequest(id: string) {
  markPending(id);

  try {
    const response = await fetch(`/api/requests/${id}/approve`, {
      method: "POST",
    });

    if (response.status === 403) {
      await reloadCurrentUser();
      await reloadRequest(id);
      showError("承認権限がありません");
      return;
    }

    if (!response.ok) {
      throw new Error("failed to approve request");
    }

    const savedRequest = await response.json();
    replaceRequest(savedRequest);
  } finally {
    unmarkPending(id);
  }
}

レビューでは、API拒否時にUIが安全側へ戻るかを確認する。

レビュー観点4:権限判定が各コンポーネントに散っていないか

画面のあちこちで role === "admin" のような条件が書かれると、仕様変更に弱くなる。

条件が散らばる例
{user.role === "admin" && <DeleteButton />}
{user.role === "admin" || user.role === "owner" ? <EditButton /> : null}
{user.permissions.includes("billing:read") && <BillingMenu />}

この状態では、権限仕様が変わったときに漏れが出やすい。
レビューでは、判定を意図が分かる関数やhookに寄せることを検討する。

権限判定を名前で表す例
function usePermission() {
  const currentUser = useCurrentUser();

  return {
    canDeleteUser: currentUser.permissions.includes("user:delete"),
    canEditBilling: currentUser.permissions.includes("billing:write"),
    canApproveRequest: currentUser.permissions.includes("request:approve"),
  };
}

この形なら、UI側は業務語で条件を読める。

UI側
const { canDeleteUser } = usePermission();

return canDeleteUser ? <DeleteButton userId={user.id} /> : null;

ただし、このhookも最終認可ではない。
あくまでUI表示の責務を整理するためのものである。

レビュー観点5:テストが「見えない」だけで終わっていないか

権限制御のテストでは、ボタンが非表示になることだけを確認しがちである。

不十分なテスト例
expect(screen.queryByRole("button", { name: "削除" })).not.toBeInTheDocument();

このテストだけでは、API拒否時の挙動や権限変更時の再同期は分からない。

レビューでは、次のテスト観点があるか確認したい。

  • 権限ありなら操作導線が表示される
  • 権限なしなら操作導線が非表示またはdisabledになる
  • APIが403を返したときに状態を戻す
  • 古い権限情報で操作した場合に再取得する
  • 権限判定関数の仕様が明示されている

レビューコメント例

Comment
@Reviewer: `role === "admin"` の条件がUIに直接散っています。表示制御としては動きますが、仕様変更時に漏れやすいため、`canDeleteUser` のような業務語の判定に寄せてください。
Comment
@Reviewer: UIでボタンを隠すだけでは認可の保証になりません。APIが403を返したときに、楽観的に変えた画面状態を戻し、現在ユーザー情報を再取得する流れを追加してください。

まとめ

Reactの権限制御レビューでは、UI表示と認可を混同しないことが重要である。

  • UI制御は操作導線の整理であり、認可の保証ではない
  • 権限情報の出どころを確認する
  • 非表示とdisabledの使い分けを設計する
  • API拒否時に安全側へ戻る
  • 権限判定を業務語で集約する
  • テストは非表示だけで終わらせない

ボタンが見えないことは、操作できないことの証明ではない。
レビューでは、最終的な実行可否がサーバー側で守られ、React側が失敗時に正しい状態へ戻れるかを確認したい。