Reactの権限制御をUI表示だけで済ませる実装をレビューする観点
はじめに
Reactの画面では、権限に応じてボタンやメニューを出し分けることが多い。
管理者だけ削除ボタンを表示する、編集権限がある人だけ入力欄を有効にする、といった実装である。
ただし、レビューではここを慎重に見る必要がある。
UIでボタンを隠すことは、認可そのものではない。
この記事では、Reactの権限制御をレビューするときに、UI表示とAPI認可の責務をどう分けて確認するか整理する。
まず止めたい実装
次のコードは、role が admin のときだけ削除ボタンを表示している。
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>
);
}この表示制御自体は必要かもしれない。
しかし、これだけでは認可にならない。
@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側で同じ権限判定が行われるか
@Reviewer: 権限判定にlocalStorage上のroleを使っています。これはクライアント側で変更可能な値なので、表示補助以上の前提にしないでください。API側の認可と、現在ユーザー情報の再取得方針を確認したいです。レビュー観点2:非表示とdisabledの使い分けがあるか
権限がない操作を隠すか、disabledで見せるかは、画面の意味に関わる。
| 表現 | 向いているケース |
|---|---|
| 非表示 | そのユーザーに関係ない操作 |
| disabled | 操作は存在するが今は条件を満たさない |
| 表示してエラー | 直前に権限が変わる可能性がある操作 |
たとえば、一般ユーザーに管理者メニューを見せる必要はない。
一方で、申請ボタンが承認済み状態で押せないなら、disabledで理由を見せた方がよいことがある。
レビューでは、権限制御がUXの説明責務まで持っているかを見る。
<button disabled={!canApprove || request.status !== "pending"}>
承認
</button>
{!canApprove && <p>承認権限がありません</p>}レビュー観点3:API拒否時に状態を戻しているか
UI側で権限があると判断しても、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を返しても画面上は承認済みになる。
楽観的更新と権限制御が重なると、事故が見えにくい。
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側は業務語で条件を読める。
const { canDeleteUser } = usePermission();
return canDeleteUser ? <DeleteButton userId={user.id} /> : null;ただし、このhookも最終認可ではない。
あくまでUI表示の責務を整理するためのものである。
レビュー観点5:テストが「見えない」だけで終わっていないか
権限制御のテストでは、ボタンが非表示になることだけを確認しがちである。
expect(screen.queryByRole("button", { name: "削除" })).not.toBeInTheDocument();このテストだけでは、API拒否時の挙動や権限変更時の再同期は分からない。
レビューでは、次のテスト観点があるか確認したい。
- 権限ありなら操作導線が表示される
- 権限なしなら操作導線が非表示またはdisabledになる
- APIが403を返したときに状態を戻す
- 古い権限情報で操作した場合に再取得する
- 権限判定関数の仕様が明示されている
レビューコメント例
@Reviewer: `role === "admin"` の条件がUIに直接散っています。表示制御としては動きますが、仕様変更時に漏れやすいため、`canDeleteUser` のような業務語の判定に寄せてください。@Reviewer: UIでボタンを隠すだけでは認可の保証になりません。APIが403を返したときに、楽観的に変えた画面状態を戻し、現在ユーザー情報を再取得する流れを追加してください。まとめ
Reactの権限制御レビューでは、UI表示と認可を混同しないことが重要である。
- UI制御は操作導線の整理であり、認可の保証ではない
- 権限情報の出どころを確認する
- 非表示とdisabledの使い分けを設計する
- API拒否時に安全側へ戻る
- 権限判定を業務語で集約する
- テストは非表示だけで終わらせない
ボタンが見えないことは、操作できないことの証明ではない。
レビューでは、最終的な実行可否がサーバー側で守られ、React側が失敗時に正しい状態へ戻れるかを確認したい。